diff --git a/.env.example b/.env.example index addb2fd..dd70afa 100644 --- a/.env.example +++ b/.env.example @@ -15,6 +15,10 @@ BACKUP_KEEP_DAYS=14 # Address the app port 8000 listens on: 127.0.0.1 = this machine only (default, safe behind the # tunnel); 0.0.0.0 = reachable from the network (home LAN: also SESSION_COOKIE_SECURE=false) API_BIND=127.0.0.1 +# Language of the texts written in the background: Telegram alerts, news summaries, stored notes +# ("en" or "it"). The dashboard has its own IT/EN button and sends its choice with every request. +APP_LANGUAGE=en + SIMILARITY_THRESHOLD=0.92 EMBEDDING_MODEL=paraphrase-multilingual-MiniLM-L12-v2 diff --git a/README.it.md b/README.it.md new file mode 100644 index 0000000..7fa0a26 --- /dev/null +++ b/README.it.md @@ -0,0 +1,288 @@ +
+ +# News × Markets + +**Dalle notizie alle probabilità: un aggregatore di notizie che stima gli eventi quotati su [Polymarket], li confronta con il prezzo e dice cosa conviene fare.** + +[![Test](https://github.com/botta0oss/News_Aggregator/actions/workflows/tests.yml/badge.svg)](https://github.com/botta0oss/News_Aggregator/actions/workflows/tests.yml) +![Python](https://img.shields.io/badge/python-3.11-3776AB?logo=python&logoColor=white) +![FastAPI](https://img.shields.io/badge/FastAPI-009688?logo=fastapi&logoColor=white) +![PostgreSQL](https://img.shields.io/badge/PostgreSQL_16-pgvector-4169E1?logo=postgresql&logoColor=white) +![Docker](https://img.shields.io/badge/Docker-CPU_%7C_CUDA-2496ED?logo=docker&logoColor=white) + +[English](README.md) · **Italiano** + +[Funzionalità](#funzionalità) · [Come funziona](#come-funziona) · [Avvio rapido](#avvio-rapido) · [Messa online](#messa-online) · [Documentazione](#documentazione) + +
+ + + + La pagina Opportunità: per ogni mercato il prezzo, la stima di Jev e la probabilità finale sulla stessa scala, con segnale ed edge + + +
+ +> [!WARNING] +> **Non è consulenza finanziaria.** L'app non piazza ordini: produce indicazioni da verificare. +> Prima di usare soldi veri controlla i risultati su molti mercati risolti +> ([backtest e calibrazione](docs/it/verifica.md)). +> +> **L'umano parla ora:** Questa schifezza è vibecodata al 1000%, ho voluto testare Claude Opus 5.5 +> e Jev di TypeSafe AI per vedere le capacità di queste nuove tecnologie. Non fidatevi +> di questa webapp per prendere decisioni finanziarie o fare scommesse. Sfruttate il +> portafoglio simulato all'interno del programma se vi interessa vedere come performa. + +## In breve + +News × Markets raccoglie notizie da feed RSS e ricerche mirate, le classifica con +[TypeSafe Jev](https://typesafe.ai) e le collega ai mercati di Polymarket. Per ogni mercato +Jev stima la probabilità dell'esito senza vedere il prezzo; la stima, corretta con i risultati +passati e pesata per la forza delle notizie, viene confrontata con il prezzo. + +Quando c'è un vantaggio, l'app lo traduce in un **piano concreto**: comprare o no, con quale +ordine limite, quanto puntare, quando vendere e **perché sì o perché no**. Un portafoglio +simulato, il backtest e la calibrazione misurano se i segnali funzionano davvero. + +La dashboard è in **italiano e inglese** (pulsante IT/EN in alto a destra). + +## Funzionalità + + + + + + + + + + +
+ +### 📰 Notizie +- Fonti RSS gestite dalla dashboard, con catalogo di fonti consigliate +- Ricerca mirata su Google News per i mercati più scambiati +- Deduplicazione per storia: conferme contate per testata, non per articolo +- Riassunti (Gemini, Groq, Ollama) e classificazione con Jev +- Ricerca full-text con evidenziazione e filtri + + + +### 🎯 Previsioni +- Collegamento notizie–mercati: significato (embedding multilingue) + termini chiave +- Stima indipendente di Jev, senza vedere il prezzo +- Calibrazione di Platt e unione col prezzo in log-odds +- Mercati Sì/No e **a più esiti** (elezioni, campionati), con arbitraggio segnalato +- Allerte Telegram quando una notizia anticipa il prezzo + +
+ +### 💶 Decisioni +- Prezzo reale dal book, commissioni, incertezza, rendimento annuo +- Kelly calcolato sul book, con tre preset di rischio +- **Cosa fare**: compra, aspetta, evita, tieni o vendi, con ordini limite +- Prezzi a cui la decisione cambierebbe e regola di vendita esplicita +- Motivi a favore e contro, con un livello di fiducia + + + +### 📊 Misura +- Portafoglio simulato con acquisti e vendite automatiche +- Backtest su mercati risolti, con notizie d'archivio senza senno di poi +- Intervalli di confidenza e validazione sui mercati più recenti +- Prezzo di chiusura (CLV) di segnali, scommesse e allerte +- Uso e costi delle API a pagamento, con limiti giornalieri + +
+ +## Uno sguardo alla dashboard + + + + + + + + + + +
+ + + Scheda Cosa fare: azione, ordini limite, scala dei prezzi, motivi a favore e contro + +
Cosa fare: ordini, prezzi e motivi +
+ + + Evento a più esiti: distribuzione di prezzo, stima di Jev e probabilità finale per ogni candidato + +
Più esiti: la distribuzione, esito per esito +
+ + + Portafoglio simulato: valore, profitti, prezzo di chiusura, preset di rischio + +
Portafoglio simulato: risultati prima dei soldi veri +
+ + + Backtest: Brier score di Jev e del prezzo, segnali giusti, scommesse simulate, risultati per orizzonte + +
Backtest: come sarebbe andata sul passato +
+ +Screenshot con dati dimostrativi. Tema chiaro e scuro seguono le impostazioni del tuo GitHub. + +## Come funziona + +```mermaid +flowchart LR + subgraph Fonti + RSS[Feed RSS] + GN[Google News
ricerca mirata] + PM[Polymarket
Gamma + CLOB] + end + RSS --> ING[Raccolta
dedup per storia] + GN --> ING + ING --> CLS[Riassunto
classificazione Jev] + CLS --> DB[(PostgreSQL
+ pgvector)] + PM --> DB + DB --> LINK[Collegamento
notizie ↔ mercati] + LINK --> JEV[Stima Jev
calibrata] + JEV --> BLEND[Unione col prezzo
edge] + BLEND --> ECO[Valutazione
economica] + ECO --> PLAN[Cosa fare
ordini e motivi] + PLAN --> OUT[Dashboard · Telegram
portafoglio simulato] +``` + +1. **Raccolta.** Le notizie arrivano da feed RSS e da ricerche mirate; la stessa storia + riscritta da più testate diventa un'unica voce, con il numero di testate che la confermano. +2. **Collegamento.** Ogni mercato riceve le notizie sullo stesso soggetto, scelte per + pertinenza, affidabilità della fonte e freschezza. +3. **Previsione.** Jev legge regole del mercato e notizie e stima la probabilità senza vedere + il prezzo. La stima viene calibrata sui mercati già risolti e unita al prezzo con un peso + che cresce con la forza delle notizie. +4. **Decisione.** Prezzo reale dal book, commissioni, incertezza, tempo e limiti di rischio + dicono se conviene, quanto puntare e a che prezzo vendere. +5. **Verifica.** Portafoglio simulato, backtest, calibrazione e prezzo di chiusura dicono se + il vantaggio è reale. + +Dettagli e formule: [Il metodo](docs/it/metodo.md) · [Strategia e portafoglio](docs/it/strategia.md). + +## Avvio rapido + +**Con Docker** (consigliato). Serve almeno la chiave di [TypeSafe](https://typesafe.ai) per le previsioni. + +```bash +git clone https://github.com/botta0oss/News_Aggregator.git && cd News_Aggregator +cp .env.example .env # inserisci almeno TYPESAFE_API_KEY e POSTGRES_PASSWORD +docker compose up --build +docker compose exec api python -m backend.auth.cli create-user tuonome --role admin +``` + +Apri **http://localhost:8000** e accedi con l'utente appena creato. Il compose avvia anche +Postgres con pgvector; alla prima partenza la raccolta delle notizie e il sync dei mercati +iniziano subito. Per aprirla dal telefono o da un altro PC di casa vedi +[Uso nella rete di casa](docs/it/deploy.md#uso-in-locale-e-nella-rete-di-casa). + +> [!TIP] +> Con una GPU NVIDIA in locale: +> `docker compose -f docker-compose.yml -f docker-compose.gpu.yml up --build`. +> Senza GPU l'immagine predefinita usa PyTorch solo CPU, molto più leggera. +> Vedi [Immagini Docker](docs/it/deploy.md#immagini-docker-cpu-o-gpu). + +
+Senza Docker + +
+ +Serve un PostgreSQL con l'estensione [pgvector](https://github.com/pgvector/pgvector). + +```bash +python -m venv .venv && source .venv/bin/activate +pip install torch --index-url https://download.pytorch.org/whl/cpu # solo senza GPU +pip install -r requirements.txt +cp .env.example .env # imposta DATABASE_URL e le chiavi +python -m backend.auth.cli create-user tuonome --role admin +uvicorn backend.main:app --reload +``` + +`python scripts/check_env.py` controlla quali chiavi sono configurate e se il database +risponde. Le tabelle vengono create all'avvio e aggiornate da migrazioni idempotenti. + +
+ +## Messa online + +Il modo più economico: un piccolo VPS con Docker e **Cloudflare Tunnel** per l'HTTPS, senza +porte aperte né certificati da gestire. Il compose include il tunnel e un backup giornaliero +del database. + +| | Costo indicativo | +|---|---| +| VPS 2 vCore, 4 GB RAM (OVH VPS-1, Hetzner…) | circa 4–6 € al mese | +| Cloudflare Tunnel, HTTPS, dominio gestito da Cloudflare | gratuito (il dominio si paga a parte) | +| Riassunti con Gemini o Groq, Telegram, Polymarket, Google News | piani gratuiti | +| Previsioni con Jev (TypeSafe) | a consumo, con [limiti giornalieri](docs/it/costi.md) | + +Guida passo passo: **[Deploy su un VPS con Cloudflare Tunnel](docs/it/deploy.md#deploy-su-un-vps-con-cloudflare-tunnel)**. + +## Configurazione essenziale + +Tutto si imposta in `.env` (copia di [`.env.example`](.env.example)). Le più importanti: + +| Variabile | A cosa serve | +|---|---| +| `TYPESAFE_API_KEY` | Previsioni e classificazione con Jev. Senza, classificazione euristica e niente previsioni | +| `GEMINI_API_KEY` / `GROQ_API_KEY` | Riassunti delle notizie (piani gratuiti) | +| `POSTGRES_PASSWORD` | Password del database nel compose, da scegliere prima del primo avvio | +| `DAILY_JEV_CALL_LIMIT`, `DAILY_AI_BUDGET_USD` | Tetto giornaliero alle chiamate e alla spesa | +| `PREDICTION_AUTO` | Previsioni automatiche dopo ogni raccolta (di base spente) | +| `TELEGRAM_BOT_TOKEN`, `TELEGRAM_CHAT_ID` | Notifiche delle allerte | +| `PUBLIC_URL` | Indirizzo della dashboard, per i link nelle notifiche | +| `APP_LANGUAGE` | Lingua delle allerte Telegram e dei riassunti: `en` (predefinita) o `it` | + +Tutte le altre: **[Configurazione](docs/it/configurazione.md)**. + +## Documentazione + +| Pagina | Contenuto | +|---|---| +| [Guida alla dashboard](docs/it/guida.md) | Le pagine, il flusso di lavoro consigliato, cosa fare se la dashboard non si apre | +| [Il metodo](docs/it/metodo.md) | Raccolta, collegamento notizie–mercati, previsione, mercati a più esiti | +| [Strategia e portafoglio](docs/it/strategia.md) | Valutazione economica, cosa fare e quando vendere, portafoglio simulato | +| [Allerte](docs/it/allerte.md) | Notifiche Telegram e misura del loro anticipo sul prezzo | +| [Backtest e calibrazione](docs/it/verifica.md) | Quanto fidarsi delle previsioni, sul passato e sul presente | +| [Uso e costi](docs/it/costi.md) | Chiamate a pagamento, stima dei costi, limiti giornalieri | +| [Configurazione](docs/it/configurazione.md) | Tutte le variabili di `.env` | +| [Accesso e sicurezza](docs/it/sicurezza.md) | Utenti e ruoli, sessioni, protezioni | +| [Deploy](docs/it/deploy.md) | Immagini CPU/GPU, uso in locale e nella rete di casa, VPS con Cloudflare Tunnel, backup | +| [API](docs/it/api.md) | Gli endpoint REST | +| [Sviluppo e test](docs/it/sviluppo.md) | Test, CI, struttura del codice | + +## Tecnologie + +| Livello | Strumenti | +|---|---| +| Backend | FastAPI, SQLAlchemy async + asyncpg, APScheduler | +| Dati | PostgreSQL 16 con pgvector | +| Modelli | TypeSafe Jev (previsioni e classificazione), sentence-transformers `paraphrase-multilingual-MiniLM-L12-v2` (embedding), Gemini / Groq / Ollama (riassunti) | +| Frontend | HTML, CSS e JavaScript senza dipendenze né build, tema chiaro e scuro, italiano e inglese, accessibile da tastiera | +| Infrastruttura | Docker (CPU o CUDA), Cloudflare Tunnel, GitHub Actions | + +## Limiti noti + +- **Nessuna esecuzione di ordini.** Per piazzarli servirebbero l'API CLOB di Polymarket, un + wallet e la firma degli ordini. Il portafoglio è solo simulato e ipotizza di comprare al + book del momento senza muovere il mercato oltre la profondità letta. +- **Commissioni.** Il tasso dipende dalla categoria e l'endpoint `/fee-rate` dice solo se un + mercato ne è esente: la documentazione di Polymarket e la sua API non coincidono. +- **Soglie di similarità** tarate sul modello di embedding precedente: con quello multilingue + vanno verificate sui propri dati. +- **Ricerca mirata via Google News RSS**: servizio non ufficiale, senza garanzie; i risultati + hanno solo titolo e testata. +- **Nessun recupero password via email**: un admin la reimposta con + `python -m backend.auth.cli set-password`. + +[Polymarket]: https://polymarket.com diff --git a/README.md b/README.md index 827545e..f549441 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ # News × Markets -**Dalle notizie alle probabilità: un aggregatore di notizie che stima gli eventi quotati su [Polymarket], li confronta con il prezzo e dice cosa conviene fare.** +**From news to probabilities: a news aggregator that estimates the events listed on [Polymarket], compares the estimate with the price and says what is worth doing.** [![Test](https://github.com/botta0oss/News_Aggregator/actions/workflows/tests.yml/badge.svg)](https://github.com/botta0oss/News_Aggregator/actions/workflows/tests.yml) ![Python](https://img.shields.io/badge/python-3.11-3776AB?logo=python&logoColor=white) @@ -10,274 +10,280 @@ ![PostgreSQL](https://img.shields.io/badge/PostgreSQL_16-pgvector-4169E1?logo=postgresql&logoColor=white) ![Docker](https://img.shields.io/badge/Docker-CPU_%7C_CUDA-2496ED?logo=docker&logoColor=white) -[Funzionalità](#funzionalità) · [Come funziona](#come-funziona) · [Avvio rapido](#avvio-rapido) · [Messa online](#messa-online) · [Documentazione](#documentazione) +**English** · [Italiano](README.it.md) + +[Features](#features) · [How it works](#how-it-works) · [Quick start](#quick-start) · [Going online](#going-online) · [Documentation](#documentation)
- La pagina Opportunità: per ogni mercato il prezzo, la stima di Jev e la probabilità finale sulla stessa scala, con segnale ed edge + The Opportunities page: for each market the price, Jev's estimate and the final probability on the same scale, with signal and edge > [!WARNING] -> **Non è consulenza finanziaria.** L'app non piazza ordini: produce indicazioni da verificare. -> Prima di usare soldi veri controlla i risultati su molti mercati risolti -> ([backtest e calibrazione](docs/verifica.md)). +> **This is not financial advice.** The app does not place orders: it produces suggestions to be checked. +> Before using real money, check the results over many resolved markets +> ([backtest and calibration](docs/verification.md)). > -> **L'umano parla ora:** Questa schifezza è vibecodata al 1000%, ho voluto testare Claude Opus 5.5 -> e Jev di TypeSafe AI per vedere le capacità di queste nuove tecnologie. Non fidatevi -> di questa webapp per prendere decisioni finanziarie o fare scommesse. Sfruttate il -> portafoglio simulato all'interno del programma se vi interessa vedere come performa. +> **The human speaking now:** this thing is 1000% vibe-coded; I wanted to test the latest AI +> models and TypeSafe AI's Jev to see what these new technologies can do. Do not trust this web +> app to make financial decisions or place bets. Use the simulated portfolio inside the app if +> you want to see how it performs. + +## In short -## In breve +News × Markets collects news from RSS feeds and targeted searches, classifies it with +[TypeSafe Jev](https://typesafe.ai) and links it to Polymarket markets. For each market Jev +estimates the probability of the outcome without seeing the price; the estimate, corrected with +past results and weighted by the strength of the news, is compared with the price. -News × Markets raccoglie notizie da feed RSS e ricerche mirate, le classifica con -[TypeSafe Jev](https://typesafe.ai) e le collega ai mercati di Polymarket. Per ogni mercato -Jev stima la probabilità dell'esito senza vedere il prezzo; la stima, corretta con i risultati -passati e pesata per la forza delle notizie, viene confrontata con il prezzo. +When there is an edge, the app turns it into a **concrete plan**: whether to buy, with which +limit order, how much to stake, when to sell, and **why or why not**. A simulated portfolio, +the backtest and the calibration measure whether the signals really work. -Quando c'è un vantaggio, l'app lo traduce in un **piano concreto**: comprare o no, con quale -ordine limite, quanto puntare, quando vendere e **perché sì o perché no**. Un portafoglio -simulato, il backtest e la calibrazione misurano se i segnali funzionano davvero. +The dashboard is available in **English and Italian** (IT/EN button at the top right). -## Funzionalità +## Features
-### 📰 Notizie -- Fonti RSS gestite dalla dashboard, con catalogo di fonti consigliate -- Ricerca mirata su Google News per i mercati più scambiati -- Deduplicazione per storia: conferme contate per testata, non per articolo -- Riassunti (Gemini, Groq, Ollama) e classificazione con Jev -- Ricerca full-text con evidenziazione e filtri +### 📰 News +- RSS sources managed from the dashboard, with a catalogue of recommended sources +- Targeted Google News search for the most traded markets +- Story-level deduplication: confirmations are counted per outlet, not per article +- Summaries (Gemini, Groq, Ollama) and classification with Jev +- Full-text search with highlighting and filters -### 🎯 Previsioni -- Collegamento notizie–mercati: significato (embedding multilingue) + termini chiave -- Stima indipendente di Jev, senza vedere il prezzo -- Calibrazione di Platt e unione col prezzo in log-odds -- Mercati Sì/No e **a più esiti** (elezioni, campionati), con arbitraggio segnalato -- Allerte Telegram quando una notizia anticipa il prezzo +### 🎯 Forecasts +- News–market linking: meaning (multilingual embeddings) + key terms +- Jev's independent estimate, without seeing the price +- Platt calibration and log-odds pooling with the price +- Yes/No markets and **multi-outcome** ones (elections, leagues), with arbitrage flagged +- Telegram alerts when a news item gets ahead of the price
-### 💶 Decisioni -- Prezzo reale dal book, commissioni, incertezza, rendimento annuo -- Kelly calcolato sul book, con tre preset di rischio -- **Cosa fare**: compra, aspetta, evita, tieni o vendi, con ordini limite -- Prezzi a cui la decisione cambierebbe e regola di vendita esplicita -- Motivi a favore e contro, con un livello di fiducia +### 💶 Decisions +- Real price from the order book, fees, uncertainty, annualised return +- Kelly computed on the book, with three risk presets +- **What to do**: buy, wait, avoid, hold or sell, with limit orders +- Prices at which the decision would change, and an explicit selling rule +- Reasons for and against, with a confidence level -### 📊 Misura -- Portafoglio simulato con acquisti e vendite automatiche -- Backtest su mercati risolti, con notizie d'archivio senza senno di poi -- Intervalli di confidenza e validazione sui mercati più recenti -- Prezzo di chiusura (CLV) di segnali, scommesse e allerte -- Uso e costi delle API a pagamento, con limiti giornalieri +### 📊 Measurement +- Simulated portfolio with automatic buys and sells +- Backtest on resolved markets, with archive news free of hindsight +- Confidence intervals and validation on the most recent markets +- Closing line value (CLV) of signals, bets and alerts +- Usage and cost of the paid APIs, with daily limits
-## Uno sguardo alla dashboard +## A look at the dashboard
- Scheda Cosa fare: azione, ordini limite, scala dei prezzi, motivi a favore e contro + What to do card: action, limit orders, price scale, reasons for and against -
Cosa fare: ordini, prezzi e motivi +
What to do: orders, prices and reasons
- Evento a più esiti: distribuzione di prezzo, stima di Jev e probabilità finale per ogni candidato + Multi-outcome event: price distribution, Jev's estimate and final probability for each candidate -
Più esiti: la distribuzione, esito per esito +
Multi-outcome: the distribution, outcome by outcome
- Portafoglio simulato: valore, profitti, prezzo di chiusura, preset di rischio + Simulated portfolio: value, profits, closing price, risk presets -
Portafoglio simulato: risultati prima dei soldi veri +
Simulated portfolio: results before real money
- Backtest: Brier score di Jev e del prezzo, segnali giusti, scommesse simulate, risultati per orizzonte + Backtest: Brier score of Jev and of the price, right signals, simulated bets, results by horizon -
Backtest: come sarebbe andata sul passato +
Backtest: how it would have gone in the past
-Screenshot con dati dimostrativi. Tema chiaro e scuro seguono le impostazioni del tuo GitHub. +Screenshots with demo data. Light and dark themes follow your GitHub settings. -## Come funziona +## How it works ```mermaid flowchart LR - subgraph Fonti - RSS[Feed RSS] - GN[Google News
ricerca mirata] + subgraph Sources + RSS[RSS feeds] + GN[Google News
targeted search] PM[Polymarket
Gamma + CLOB] end - RSS --> ING[Raccolta
dedup per storia] + RSS --> ING[Collection
story dedup] GN --> ING - ING --> CLS[Riassunto
classificazione Jev] + ING --> CLS[Summary
Jev classification] CLS --> DB[(PostgreSQL
+ pgvector)] PM --> DB - DB --> LINK[Collegamento
notizie ↔ mercati] - LINK --> JEV[Stima Jev
calibrata] - JEV --> BLEND[Unione col prezzo
edge] - BLEND --> ECO[Valutazione
economica] - ECO --> PLAN[Cosa fare
ordini e motivi] - PLAN --> OUT[Dashboard · Telegram
portafoglio simulato] + DB --> LINK[Linking
news ↔ markets] + LINK --> JEV[Calibrated
Jev estimate] + JEV --> BLEND[Pooling with the price
edge] + BLEND --> ECO[Economic
assessment] + ECO --> PLAN[What to do
orders and reasons] + PLAN --> OUT[Dashboard · Telegram
simulated portfolio] ``` -1. **Raccolta.** Le notizie arrivano da feed RSS e da ricerche mirate; la stessa storia - riscritta da più testate diventa un'unica voce, con il numero di testate che la confermano. -2. **Collegamento.** Ogni mercato riceve le notizie sullo stesso soggetto, scelte per - pertinenza, affidabilità della fonte e freschezza. -3. **Previsione.** Jev legge regole del mercato e notizie e stima la probabilità senza vedere - il prezzo. La stima viene calibrata sui mercati già risolti e unita al prezzo con un peso - che cresce con la forza delle notizie. -4. **Decisione.** Prezzo reale dal book, commissioni, incertezza, tempo e limiti di rischio - dicono se conviene, quanto puntare e a che prezzo vendere. -5. **Verifica.** Portafoglio simulato, backtest, calibrazione e prezzo di chiusura dicono se - il vantaggio è reale. +1. **Collection.** News comes from RSS feeds and targeted searches; the same story rewritten + by several outlets becomes a single entry, with the number of outlets confirming it. +2. **Linking.** Each market gets the news about the same subject, chosen by relevance, + source reliability and freshness. +3. **Forecast.** Jev reads the market rules and the news and estimates the probability without + seeing the price. The estimate is calibrated on markets already resolved and pooled with the + price, with a weight that grows with the strength of the news. +4. **Decision.** Real price from the book, fees, uncertainty, time and risk limits say whether + it is worth it, how much to stake and at what price to sell. +5. **Verification.** Simulated portfolio, backtest, calibration and closing price say whether + the edge is real. -Dettagli e formule: [Il metodo](docs/metodo.md) · [Strategia e portafoglio](docs/strategia.md). +Details and formulas: [The method](docs/method.md) · [Strategy and portfolio](docs/strategy.md). -## Avvio rapido +## Quick start -**Con Docker** (consigliato). Serve almeno la chiave di [TypeSafe](https://typesafe.ai) per le previsioni. +**With Docker** (recommended). You need at least a [TypeSafe](https://typesafe.ai) key for forecasts. ```bash git clone https://github.com/botta0oss/News_Aggregator.git && cd News_Aggregator -cp .env.example .env # inserisci almeno TYPESAFE_API_KEY e POSTGRES_PASSWORD +cp .env.example .env # set at least TYPESAFE_API_KEY and POSTGRES_PASSWORD docker compose up --build -docker compose exec api python -m backend.auth.cli create-user tuonome --role admin +docker compose exec api python -m backend.auth.cli create-user yourname --role admin ``` -Apri **http://localhost:8000** e accedi con l'utente appena creato. Il compose avvia anche -Postgres con pgvector; alla prima partenza la raccolta delle notizie e il sync dei mercati -iniziano subito. Per aprirla dal telefono o da un altro PC di casa vedi -[Uso nella rete di casa](docs/deploy.md#uso-in-locale-e-nella-rete-di-casa). +Open **http://localhost:8000** and sign in with the user you just created. Compose also starts +Postgres with pgvector; on the first start news collection and market sync begin right away. +To open it from your phone or another computer at home see +[Use on your home network](docs/deploy.md#local-use-and-home-network). > [!TIP] -> Con una GPU NVIDIA in locale: +> With a local NVIDIA GPU: > `docker compose -f docker-compose.yml -f docker-compose.gpu.yml up --build`. -> Senza GPU l'immagine predefinita usa PyTorch solo CPU, molto più leggera. -> Vedi [Immagini Docker](docs/deploy.md#immagini-docker-cpu-o-gpu). +> Without a GPU the default image uses CPU-only PyTorch, much lighter. +> See [Docker images](docs/deploy.md#docker-images-cpu-or-gpu).
-Senza Docker +Without Docker
-Serve un PostgreSQL con l'estensione [pgvector](https://github.com/pgvector/pgvector). +You need PostgreSQL with the [pgvector](https://github.com/pgvector/pgvector) extension. ```bash python -m venv .venv && source .venv/bin/activate -pip install torch --index-url https://download.pytorch.org/whl/cpu # solo senza GPU +pip install torch --index-url https://download.pytorch.org/whl/cpu # only without a GPU pip install -r requirements.txt -cp .env.example .env # imposta DATABASE_URL e le chiavi -python -m backend.auth.cli create-user tuonome --role admin +cp .env.example .env # set DATABASE_URL and the keys +python -m backend.auth.cli create-user yourname --role admin uvicorn backend.main:app --reload ``` -`python scripts/check_env.py` controlla quali chiavi sono configurate e se il database -risponde. Le tabelle vengono create all'avvio e aggiornate da migrazioni idempotenti. +`python scripts/check_env.py` checks which keys are configured and whether the database +answers. Tables are created at startup and updated by idempotent migrations.
-## Messa online +## Going online -Il modo più economico: un piccolo VPS con Docker e **Cloudflare Tunnel** per l'HTTPS, senza -porte aperte né certificati da gestire. Il compose include il tunnel e un backup giornaliero -del database. +The cheapest way: a small VPS with Docker and **Cloudflare Tunnel** for HTTPS, with no open +ports and no certificates to manage. Compose includes the tunnel and a daily database backup. -| | Costo indicativo | +| | Indicative cost | |---|---| -| VPS 2 vCore, 4 GB RAM (OVH VPS-1, Hetzner…) | circa 4–6 € al mese | -| Cloudflare Tunnel, HTTPS, dominio gestito da Cloudflare | gratuito (il dominio si paga a parte) | -| Riassunti con Gemini o Groq, Telegram, Polymarket, Google News | piani gratuiti | -| Previsioni con Jev (TypeSafe) | a consumo, con [limiti giornalieri](docs/costi.md) | +| VPS with 2 vCores, 4 GB RAM (OVH VPS-1, Hetzner…) | about €4–6 a month | +| Cloudflare Tunnel, HTTPS, domain managed by Cloudflare | free (the domain is paid separately) | +| Summaries with Gemini or Groq, Telegram, Polymarket, Google News | free tiers | +| Forecasts with Jev (TypeSafe) | pay per use, with [daily limits](docs/costs.md) | -Guida passo passo: **[Deploy su un VPS con Cloudflare Tunnel](docs/deploy.md#deploy-su-un-vps-con-cloudflare-tunnel)**. +Step-by-step guide: **[Deploy on a VPS with Cloudflare Tunnel](docs/deploy.md#deploy-on-a-vps-with-cloudflare-tunnel)**. -## Configurazione essenziale +## Essential configuration -Tutto si imposta in `.env` (copia di [`.env.example`](.env.example)). Le più importanti: +Everything is set in `.env` (a copy of [`.env.example`](.env.example)). The most important: -| Variabile | A cosa serve | +| Variable | What it is for | |---|---| -| `TYPESAFE_API_KEY` | Previsioni e classificazione con Jev. Senza, classificazione euristica e niente previsioni | -| `GEMINI_API_KEY` / `GROQ_API_KEY` | Riassunti delle notizie (piani gratuiti) | -| `POSTGRES_PASSWORD` | Password del database nel compose, da scegliere prima del primo avvio | -| `DAILY_JEV_CALL_LIMIT`, `DAILY_AI_BUDGET_USD` | Tetto giornaliero alle chiamate e alla spesa | -| `PREDICTION_AUTO` | Previsioni automatiche dopo ogni raccolta (di base spente) | -| `TELEGRAM_BOT_TOKEN`, `TELEGRAM_CHAT_ID` | Notifiche delle allerte | -| `PUBLIC_URL` | Indirizzo della dashboard, per i link nelle notifiche | +| `TYPESAFE_API_KEY` | Forecasts and classification with Jev. Without it: heuristic classification and no forecasts | +| `GEMINI_API_KEY` / `GROQ_API_KEY` | News summaries (free tiers) | +| `POSTGRES_PASSWORD` | Database password in compose, to choose before the first start | +| `DAILY_JEV_CALL_LIMIT`, `DAILY_AI_BUDGET_USD` | Daily cap on calls and spend | +| `PREDICTION_AUTO` | Automatic forecasts after every collection (off by default) | +| `TELEGRAM_BOT_TOKEN`, `TELEGRAM_CHAT_ID` | Alert notifications | +| `PUBLIC_URL` | Dashboard address, for the links in notifications | +| `APP_LANGUAGE` | Language of Telegram alerts and news summaries: `en` (default) or `it` | -Tutte le altre: **[Configurazione](docs/configurazione.md)**. +All the others: **[Configuration](docs/configuration.md)**. -## Documentazione +## Documentation -| Pagina | Contenuto | +| Page | Contents | |---|---| -| [Guida alla dashboard](docs/guida.md) | Le pagine, il flusso di lavoro consigliato, cosa fare se la dashboard non si apre | -| [Il metodo](docs/metodo.md) | Raccolta, collegamento notizie–mercati, previsione, mercati a più esiti | -| [Strategia e portafoglio](docs/strategia.md) | Valutazione economica, cosa fare e quando vendere, portafoglio simulato | -| [Allerte](docs/allerte.md) | Notifiche Telegram e misura del loro anticipo sul prezzo | -| [Backtest e calibrazione](docs/verifica.md) | Quanto fidarsi delle previsioni, sul passato e sul presente | -| [Uso e costi](docs/costi.md) | Chiamate a pagamento, stima dei costi, limiti giornalieri | -| [Configurazione](docs/configurazione.md) | Tutte le variabili di `.env` | -| [Accesso e sicurezza](docs/sicurezza.md) | Utenti e ruoli, sessioni, protezioni | -| [Deploy](docs/deploy.md) | Immagini CPU/GPU, uso in locale e nella rete di casa, VPS con Cloudflare Tunnel, backup | -| [API](docs/api.md) | Gli endpoint REST | -| [Sviluppo e test](docs/sviluppo.md) | Test, CI, struttura del codice | - -## Tecnologie - -| Livello | Strumenti | +| [Dashboard guide](docs/guide.md) | The pages, the recommended workflow, what to do if the dashboard does not open | +| [The method](docs/method.md) | Collection, news–market linking, forecasting, multi-outcome markets | +| [Strategy and portfolio](docs/strategy.md) | Economic assessment, what to do and when to sell, simulated portfolio | +| [Alerts](docs/alerts.md) | Telegram notifications and how much they get ahead of the price | +| [Backtest and calibration](docs/verification.md) | How much to trust the forecasts, on the past and on the present | +| [Usage and costs](docs/costs.md) | Paid calls, cost estimate, daily limits | +| [Configuration](docs/configuration.md) | Every `.env` variable | +| [Access and security](docs/security.md) | Users and roles, sessions, protections | +| [Deploy](docs/deploy.md) | CPU/GPU images, local and home-network use, VPS with Cloudflare Tunnel, backups | +| [API](docs/api.md) | The REST endpoints | +| [Development and tests](docs/development.md) | Tests, CI, code structure | + +The Italian documentation is in [`docs/it`](docs/it/README.md). + +## Technologies + +| Layer | Tools | |---|---| -| Backend | FastAPI, SQLAlchemy async + asyncpg, APScheduler | -| Dati | PostgreSQL 16 con pgvector | -| Modelli | TypeSafe Jev (previsioni e classificazione), sentence-transformers `paraphrase-multilingual-MiniLM-L12-v2` (embedding), Gemini / Groq / Ollama (riassunti) | -| Frontend | HTML, CSS e JavaScript senza dipendenze né build, tema chiaro e scuro, accessibile da tastiera | -| Infrastruttura | Docker (CPU o CUDA), Cloudflare Tunnel, GitHub Actions | - -## Limiti noti - -- **Nessuna esecuzione di ordini.** Per piazzarli servirebbero l'API CLOB di Polymarket, un - wallet e la firma degli ordini. Il portafoglio è solo simulato e ipotizza di comprare al - book del momento senza muovere il mercato oltre la profondità letta. -- **Commissioni.** Il tasso dipende dalla categoria e l'endpoint `/fee-rate` dice solo se un - mercato ne è esente: la documentazione di Polymarket e la sua API non coincidono. -- **Soglie di similarità** tarate sul modello di embedding precedente: con quello multilingue - vanno verificate sui propri dati. -- **Ricerca mirata via Google News RSS**: servizio non ufficiale, senza garanzie; i risultati - hanno solo titolo e testata. -- **Nessun recupero password via email**: un admin la reimposta con +| Backend | FastAPI, async SQLAlchemy + asyncpg, APScheduler | +| Data | PostgreSQL 16 with pgvector | +| Models | TypeSafe Jev (forecasts and classification), sentence-transformers `paraphrase-multilingual-MiniLM-L12-v2` (embeddings), Gemini / Groq / Ollama (summaries) | +| Frontend | HTML, CSS and JavaScript with no dependencies and no build step, light and dark themes, English and Italian, keyboard accessible | +| Infrastructure | Docker (CPU or CUDA), Cloudflare Tunnel, GitHub Actions | + +## Known limits + +- **No order execution.** Placing orders would need Polymarket's CLOB API, a wallet and order + signing. The portfolio is only simulated and assumes buying at the current book without + moving the market beyond the depth it read. +- **Fees.** The rate depends on the category and the `/fee-rate` endpoint only says whether a + market is exempt: Polymarket's documentation and its API do not agree. +- **Similarity thresholds** were tuned on the previous embedding model: with the multilingual + one they should be checked on your own data. +- **Targeted search via Google News RSS**: an unofficial service, with no guarantees; results + only have a title and an outlet. +- **No password recovery by email**: an admin resets it with `python -m backend.auth.cli set-password`. [Polymarket]: https://polymarket.com diff --git a/backend/ai/summarizer.py b/backend/ai/summarizer.py index a826512..0a0b919 100644 --- a/backend/ai/summarizer.py +++ b/backend/ai/summarizer.py @@ -4,10 +4,17 @@ from backend.ai import usage from backend.ai.ratelimit import RateLimited, get_limiter, retry_after_seconds from backend.config import settings +from backend.i18n import lang logger = logging.getLogger(__name__) -SYSTEM_PROMPT = "You are an objective news editor. Write a concise 3-sentence factual summary in Italian. Return only the summary." +def _language() -> str: + """Summaries are stored once, in APP_LANGUAGE (or the language of the request that asks for them).""" + return "Italian" if lang() == "it" else "English" + + +def system_prompt() -> str: + return f"You are an objective news editor. Write a concise 3-sentence factual summary in {_language()}. Return only the summary." # Shared clients: one connection pool per provider instead of one client per article _groq_client = None @@ -45,7 +52,7 @@ async def summarize_with_gemini(title: str, content: str) -> Optional[str]: await usage.check("gemini") async with limiter.slot(): prompt = ( - "You are an objective news editor. Write a concise, 3-sentence factual summary in Italian " + f"You are an objective news editor. Write a concise, 3-sentence factual summary in {_language()} " "highlighting the key facts and significance of the event. " "Return only the summary text without introduction.\n\n" f"Title: {title}\nContent: {content[:3000]}" @@ -78,7 +85,7 @@ async def summarize_with_groq(title: str, content: str) -> Optional[str]: return None limiter = get_limiter("groq") messages = [ - {"role": "system", "content": SYSTEM_PROMPT}, + {"role": "system", "content": system_prompt()}, {"role": "user", "content": f"Title: {title}\nContent: {content[:3000]}"}, ] try: @@ -119,6 +126,9 @@ async def summarize_with_ollama(title: str, content: str) -> Optional[str]: "prompt": ( f"Sei un giornalista di precisione. Riassumi questa notizia in 3 frasi concise in italiano.\n\n" f"Titolo: {title}\nContenuto: {content[:2000]}\n\nRiassunto:" + if lang() == "it" else + f"You are a precise journalist. Summarise this news item in 3 concise sentences in English.\n\n" + f"Title: {title}\nContent: {content[:2000]}\n\nSummary:" ), "stream": False } diff --git a/backend/ai/usage.py b/backend/ai/usage.py index f923af5..179df90 100644 --- a/backend/ai/usage.py +++ b/backend/ai/usage.py @@ -20,6 +20,7 @@ from backend.ai.ratelimit import RateLimited from backend.config import settings +from backend.i18n import lang, tr logger = logging.getLogger(__name__) @@ -34,6 +35,11 @@ "backtest": "Backtest", "altro": "Altro", } +FEATURES_EN = { + "classificazione": "News classification", "riassunti": "Summaries", "riclassificazione": "Reclassification", + "previsioni": "Forecasts", "valuta_tutti": "Assess all with Jev", "allerte": "Alerts", "piu_esiti": "Multi-outcome", + "backtest": "Backtest", "altro": "Other", +} PAID = ("jev", "groq", "gemini") _feature: ContextVar[str] = ContextVar("ai_feature", default="altro") @@ -139,10 +145,12 @@ async def check(provider: str) -> None: t = await _ensure_today() if provider == "jev" and settings.DAILY_JEV_CALL_LIMIT > 0 and t.jev_calls >= settings.DAILY_JEV_CALL_LIMIT: raise BudgetExceeded(provider, seconds_to_midnight(), - f"Limite giornaliero di {settings.DAILY_JEV_CALL_LIMIT} chiamate a Jev raggiunto: si riparte a mezzanotte.") + tr(f"Limite giornaliero di {settings.DAILY_JEV_CALL_LIMIT} chiamate a Jev raggiunto: si riparte a mezzanotte.", + f"Daily limit of {settings.DAILY_JEV_CALL_LIMIT} Jev calls reached: it restarts at midnight.")) if settings.DAILY_AI_BUDGET_USD > 0 and t.cost >= settings.DAILY_AI_BUDGET_USD: raise BudgetExceeded(provider, seconds_to_midnight(), - f"Budget giornaliero di {settings.DAILY_AI_BUDGET_USD:.2f} $ per le API AI raggiunto: si riparte a mezzanotte.") + tr(f"Budget giornaliero di {settings.DAILY_AI_BUDGET_USD:.2f} $ per le API AI raggiunto: si riparte a mezzanotte.", + f"Daily AI API budget of ${settings.DAILY_AI_BUDGET_USD:.2f} reached: it restarts at midnight.")) async def record(provider: str, input_tokens: Optional[int] = 0, output_tokens: Optional[int] = 0, ok: bool = True) -> None: @@ -176,17 +184,21 @@ def shares(t: Optional[_Today] = None) -> dict: async def _maybe_warn(t: _Today) -> None: """Once per day and per threshold (warning share, then 100%): log and, if configured, Telegram.""" - labels = {"jev_calls": "delle chiamate a Jev", "cost": "del budget per le API AI"} + labels = {"jev_calls": tr("delle chiamate a Jev", "of Jev calls"), "cost": tr("del budget per le API AI", "of the AI API budget")} for key, share in shares(t).items(): if share is None: continue if share >= 1.0 and (key, "full") not in t.warned: t.warned |= {(key, "full"), (key, "warn")} - text = (f"⚠️ News × Markets: limite giornaliero {labels[key]} esaurito. Le chiamate a pagamento " - "riprendono a mezzanotte; classificazione e riassunti usano le alternative gratuite.") + text = "⚠️ News × Markets: " + tr( + f"limite giornaliero {labels[key]} esaurito. Le chiamate a pagamento " + "riprendono a mezzanotte; classificazione e riassunti usano le alternative gratuite.", + f"daily limit {labels[key]} used up. Paid calls " + "resume at midnight; classification and summaries use the free alternatives.") elif settings.USAGE_WARN_SHARE <= share < 1.0 and (key, "warn") not in t.warned: t.warned.add((key, "warn")) - text = f"⚠️ News × Markets: usato il {round(share * 100)}% del limite giornaliero {labels[key]}." + text = "⚠️ News × Markets: " + tr(f"usato il {round(share * 100)}% del limite giornaliero {labels[key]}.", + f"used {round(share * 100)}% of the daily limit {labels[key]}.") else: continue logger.warning(text) @@ -234,4 +246,5 @@ async def report(db, days: int = 30) -> dict: items = [{"day": d.isoformat(), "provider": p, "feature": f, "calls": c, "errors": e, "input_tokens": int(i), "output_tokens": int(o), "cost": round(float(cost), 4)} for d, p, f, c, e, i, o, cost in rows] - return {"days": days, "since": since.date().isoformat(), "items": items, "features": FEATURES} + features = FEATURES if lang() == "it" else {**FEATURES, **FEATURES_EN} + return {"days": days, "since": since.date().isoformat(), "items": items, "features": features} diff --git a/backend/alerts/service.py b/backend/alerts/service.py index 1128ecc..ce14de2 100644 --- a/backend/alerts/service.py +++ b/backend/alerts/service.py @@ -26,6 +26,7 @@ from backend.markets import polymarket from backend.betting.clv import summarize as clv_summary from backend.markets.matching import source_quality +from backend.i18n import side as side_label, tr logger = logging.getLogger(__name__) @@ -61,12 +62,12 @@ def validate_settings(data: dict) -> dict: if "categories" in data: cats = list(dict.fromkeys(data["categories"] or [])) if any(c not in CATEGORIES for c in cats): - raise ValueError("Categoria non valida") + raise ValueError(tr("Categoria non valida", "Invalid category")) out["categories"] = cats - ranges = {"min_match": (0.3, 1.0, "La pertinenza minima deve essere tra 30% e 100%"), - "max_news_age_hours": (0.5, 72, "L'età massima della notizia deve essere tra 0,5 e 72 ore"), - "daily_budget": (0, 500, "Il limite giornaliero deve essere tra 0 e 500 chiamate"), - "cooldown_hours": (0, 72, "La pausa per mercato deve essere tra 0 e 72 ore")} + ranges = {"min_match": (0.3, 1.0, tr("La pertinenza minima deve essere tra 30% e 100%", "The minimum match must be between 30% and 100%")), + "max_news_age_hours": (0.5, 72, tr("L'età massima della notizia deve essere tra 0,5 e 72 ore", "The maximum news age must be between 0.5 and 72 hours")), + "daily_budget": (0, 500, tr("Il limite giornaliero deve essere tra 0 e 500 chiamate", "The daily limit must be between 0 and 500 calls")), + "cooldown_hours": (0, 72, tr("La pausa per mercato deve essere tra 0 e 72 ore", "The pause per market must be between 0 and 72 hours"))} for key, (lo, hi, msg) in ranges.items(): if key in data: value = float(data[key]) @@ -75,13 +76,13 @@ def validate_settings(data: dict) -> dict: out[key] = int(value) if key == "daily_budget" else value if "min_verdict" in data: if data["min_verdict"] not in VERDICTS: - raise ValueError("Esito minimo non valido") + raise ValueError(tr("Esito minimo non valido", "Invalid minimum outcome")) out["min_verdict"] = data["min_verdict"] for key in ("quiet_start", "quiet_end"): if key in data: value = data[key] if value is not None and not (isinstance(value, int) and 0 <= value <= 23): - raise ValueError("Le ore silenziose vanno da 0 a 23") + raise ValueError(tr("Le ore silenziose vanno da 0 a 23", "Quiet hours go from 0 to 23")) out[key] = value return out @@ -357,7 +358,7 @@ async def _record(db: AsyncSession, market: Market, prediction: MarketPrediction def _short(x: float) -> str: """One decimal, none when it is zero: 34¢, 44,5¢.""" text = _num(x, 1) - return text[:-2] if text.endswith(",0") else text + return text[:-2] if text.endswith((",0", ".0")) else text def _pct(p: Optional[float]) -> str: @@ -371,24 +372,30 @@ def _cents(p: Optional[float]) -> str: def format_message(alert: Alert, market: Market, prediction: MarketPrediction, trig: dict, sell_above: Optional[float] = None) -> str: e = telegram.escape - side = "SÌ" if alert.side == "YES" else "NO" - verdict = "conviene" if alert.verdict == "GO" else "conviene, puntata piccola" + side = side_label(alert.side) + verdict = tr("conviene", "worth it") if alert.verdict == "GO" else tr("conviene, puntata piccola", "worth it, small stake") age_min = max(0, int((_now() - trig["published"]).total_seconds() // 60)) if trig.get("published") else None - age = "" if age_min is None else (f", {age_min} min fa" if age_min < 120 else f", {age_min // 60} ore fa") - head = f"Compra {side} su {e(trig['outcome'])}" if trig.get("outcome") else f"Compra {side}" + age = "" if age_min is None else (tr(f", {age_min} min fa", f", {age_min} min ago") if age_min < 120 + else tr(f", {age_min // 60} ore fa", f", {age_min // 60} hours ago")) + head = (tr(f"Compra {side} su {e(trig['outcome'])}", f"Buy {side} on {e(trig['outcome'])}") if trig.get("outcome") + else tr(f"Compra {side}", f"Buy {side}")) lines = [ f"🔔 {head} · {verdict}", f"{e(trig.get('event_title') or market.question)}", "", - f"Notizia: {e(trig['title'])} ({e(trig['source'])}{age})", - f"Prezzo SÌ {_cents(alert.price)} → stima {_pct(prediction.blended_probability)} " - f"(Jev {_pct(prediction.model_probability)}, evidenze {_pct(prediction.evidence_strength)})", - f"Edge {'+' if (alert.edge or 0) >= 0 else '−'}{_num(abs(alert.edge or 0) * 100, 1)} pt", + tr("Notizia", "News") + f": {e(trig['title'])} ({e(trig['source'])}{age})", + tr(f"Prezzo SÌ {_cents(alert.price)} → stima {_pct(prediction.blended_probability)} " + f"(Jev {_pct(prediction.model_probability)}, evidenze {_pct(prediction.evidence_strength)})", + f"YES price {_cents(alert.price)} → estimate {_pct(prediction.blended_probability)} " + f"(Jev {_pct(prediction.model_probability)}, evidence {_pct(prediction.evidence_strength)})"), + f"Edge {'+' if (alert.edge or 0) >= 0 else '−'}{_num(abs(alert.edge or 0) * 100, 1)} " + tr("pt", "pts"), ] if alert.outlay: - lines += ["", f"Ordine: compra {side} con limite {_cents(alert.limit_price)} (puntata simulata {_num(alert.outlay, 2)} $)"] + lines += ["", tr(f"Ordine: compra {side} con limite {_cents(alert.limit_price)} (puntata simulata {_num(alert.outlay, 2)} $)", + f"Order: buy {side} with limit {_cents(alert.limit_price)} (simulated stake ${_num(alert.outlay, 2)})")] if sell_above is not None: - lines.append(f"Poi: vendita limite a {_cents(sell_above)}, oppure tieni fino alla risoluzione") + lines.append(tr(f"Poi: vendita limite a {_cents(sell_above)}, oppure tieni fino alla risoluzione", + f"Then: limit sell at {_cents(sell_above)}, or hold until resolution")) if settings.PUBLIC_URL: path = f"multi/{market.multi_event_id}" if market.multi_event_id else f"mercati/{market.id}" lines += ["", f"{settings.PUBLIC_URL.rstrip('/')}/#/{path}"] diff --git a/backend/alerts/telegram.py b/backend/alerts/telegram.py index 022a934..fd86f6a 100644 --- a/backend/alerts/telegram.py +++ b/backend/alerts/telegram.py @@ -5,6 +5,7 @@ import httpx from backend.config import settings +from backend.i18n import tr logger = logging.getLogger(__name__) @@ -26,7 +27,7 @@ def escape(text) -> str: async def send_message(text: str, silent: bool = False) -> None: """Sends an HTML message to TELEGRAM_CHAT_ID. Raises TelegramError with a readable reason.""" if not is_configured(): - raise TelegramError("Telegram non è configurato: servono TELEGRAM_BOT_TOKEN e TELEGRAM_CHAT_ID") + raise TelegramError(tr("Telegram non è configurato: servono TELEGRAM_BOT_TOKEN e TELEGRAM_CHAT_ID", "Telegram is not configured: TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID are needed")) url = f"{settings.TELEGRAM_API_URL}/bot{settings.TELEGRAM_BOT_TOKEN}/sendMessage" payload = { "chat_id": settings.TELEGRAM_CHAT_ID, @@ -39,11 +40,12 @@ async def send_message(text: str, silent: bool = False) -> None: async with httpx.AsyncClient(timeout=15, transport=_transport) as client: r = await client.post(url, json=payload) except httpx.HTTPError as e: - raise TelegramError(f"Telegram non raggiungibile: {e.__class__.__name__}") from None + raise TelegramError(tr(f"Telegram non raggiungibile: {e.__class__.__name__}", f"Telegram unreachable: {e.__class__.__name__}")) from None if r.status_code != 200: try: reason = r.json().get("description") or r.text except ValueError: reason = r.text # Never log or return the URL: it contains the bot token - raise TelegramError(f"Telegram ha rifiutato il messaggio ({r.status_code}): {reason[:200]}") + raise TelegramError(tr(f"Telegram ha rifiutato il messaggio ({r.status_code}): {reason[:200]}", + f"Telegram refused the message ({r.status_code}): {reason[:200]}")) diff --git a/backend/api/routes/alerts.py b/backend/api/routes/alerts.py index 850797b..eb98ead 100644 --- a/backend/api/routes/alerts.py +++ b/backend/api/routes/alerts.py @@ -9,6 +9,7 @@ from backend.config import settings from backend.db.database import get_db from backend.db.models import Alert, Article, Market, Source +from backend.i18n import tr router = APIRouter(prefix="/alerts", tags=["alerts"]) admin = [Depends(require_admin)] @@ -58,7 +59,7 @@ async def put_settings(body: SettingsIn, db: AsyncSession = Depends(get_db)): @router.post("/test-telegram", dependencies=admin) async def test_telegram(): try: - await telegram.send_message("✅ News × Markets: le notifiche delle allerte arrivano qui.") + await telegram.send_message(tr("✅ News × Markets: le notifiche delle allerte arrivano qui.", "✅ News × Markets: alert notifications arrive here.")) except telegram.TelegramError as e: raise HTTPException(status_code=502 if telegram.is_configured() else 503, detail=str(e)) return {"ok": True} diff --git a/backend/api/routes/articles.py b/backend/api/routes/articles.py index 384eccd..ef01929 100644 --- a/backend/api/routes/articles.py +++ b/backend/api/routes/articles.py @@ -9,6 +9,7 @@ from backend.db.models import Article, ProcessedArticle, Source, Cluster from backend.api.schemas import ArticleListResponse, ArticleResponse from backend.ai.typesafe_evaluator import calculate_composite_score +from backend.i18n import tr router = APIRouter(prefix="/articles", tags=["articles"]) @@ -44,25 +45,25 @@ def _base_query(): @router.get("", response_model=ArticleListResponse) async def get_articles( - q: Optional[str] = Query(None, max_length=200, description="Testo da cercare in titolo, contenuto e riassunto"), - scope: Literal["all", "title"] = Query("all", description="Dove cercare"), - sort: Optional[Literal["relevance", "score", "recent"]] = Query(None, description="Ordinamento (default: pertinenza se c'è una ricerca)"), - category: str = Query(None, description="Filtra per macro categoria"), + q: Optional[str] = Query(None, max_length=200, description="Text to search in title, content and summary"), + scope: Literal["all", "title"] = Query("all", description="Where to search"), + sort: Optional[Literal["relevance", "score", "recent"]] = Query(None, description="Sorting (default: relevance when there is a search)"), + category: str = Query(None, description="Filter by macro category"), region: Optional[str] = Query(None, max_length=40), source_id: Optional[uuid.UUID] = Query(None), since_hours: Optional[int] = Query(None, ge=1, le=24 * 365), min_market_relevance: Optional[float] = Query(None, ge=0.0, le=1.0), - hide_opinion: bool = Query(False, description="Escludi opinioni e commenti"), + hide_opinion: bool = Query(False, description="Exclude opinion and commentary"), limit: int = Query(50, ge=1, le=200), offset: int = Query(0, ge=0), # Weight parameters for dynamic composite ranking - w_authority: float = Query(0.35, ge=0.0, le=2.0, description="Peso autorevolezza"), - w_tech: float = Query(0.25, ge=0.0, le=2.0, description="Peso profondità tecnica"), - w_urgency: float = Query(0.25, ge=0.0, le=2.0, description="Peso urgenza/breaking"), - w_clickbait: float = Query(0.40, ge=0.0, le=2.0, description="Penalità clickbait"), + w_authority: float = Query(0.35, ge=0.0, le=2.0, description="Authority weight"), + w_tech: float = Query(0.25, ge=0.0, le=2.0, description="Technical depth weight"), + w_urgency: float = Query(0.25, ge=0.0, le=2.0, description="Urgency/breaking weight"), + w_clickbait: float = Query(0.40, ge=0.0, le=2.0, description="Clickbait penalty"), # Hard thresholds - max_clickbait: float = Query(None, ge=0.0, le=1.0, description="Soglia massima tollerata clickbait"), - min_authority: float = Query(None, ge=0.0, le=1.0, description="Soglia minima autorevolezza"), + max_clickbait: float = Query(None, ge=0.0, le=1.0, description="Maximum tolerated clickbait"), + min_authority: float = Query(None, ge=0.0, le=1.0, description="Minimum authority"), db: AsyncSession = Depends(get_db) ): query = _base_query() @@ -200,5 +201,5 @@ def _article_dict(article, processed, source, cluster) -> dict: async def get_article(article_id: uuid.UUID, db: AsyncSession = Depends(get_db)): row = (await db.execute(_base_query().where(Article.id == article_id))).first() if not row: - raise HTTPException(status_code=404, detail="Article not found") + raise HTTPException(status_code=404, detail=tr("Notizia non trovata", "Article not found")) return _article_dict(*row) diff --git a/backend/api/routes/auth.py b/backend/api/routes/auth.py index 3fb4e52..d941a7d 100644 --- a/backend/api/routes/auth.py +++ b/backend/api/routes/auth.py @@ -10,6 +10,7 @@ ActiveSession, authenticate, change_password, create_session, revoke_session, ) from backend.db.database import get_db +from backend.i18n import tr logger = logging.getLogger("backend.auth") router = APIRouter(prefix="/auth", tags=["auth"]) @@ -45,7 +46,8 @@ async def login(body: LoginRequest, request: Request, response: Response, db: As username = normalize_username(body.username) wait = login_limiter.retry_after(ip, username) if wait: - raise HTTPException(status_code=429, detail=f"Troppi tentativi. Riprova tra {max(1, round(wait / 60))} minuti.", + raise HTTPException(status_code=429, detail=tr(f"Troppi tentativi. Riprova tra {max(1, round(wait / 60))} minuti.", + f"Too many attempts. Try again in {max(1, round(wait / 60))} minutes."), headers={"Retry-After": str(wait)}) user = await authenticate(db, username, body.password) @@ -53,7 +55,7 @@ async def login(body: LoginRequest, request: Request, response: Response, db: As login_limiter.record_failure(ip, username) logger.warning("Failed login for %r from %s", username, ip) # Same message whether the username exists or not - raise HTTPException(status_code=401, detail="Username o password non corretti.") + raise HTTPException(status_code=401, detail=tr("Username o password non corretti.", "Wrong username or password.")) login_limiter.reset(ip, username) await revoke_session(db, session_token(request)) # never reuse a pre-login session diff --git a/backend/api/routes/backtest.py b/backend/api/routes/backtest.py index cc521eb..4b1f7a4 100644 --- a/backend/api/routes/backtest.py +++ b/backend/api/routes/backtest.py @@ -12,6 +12,7 @@ from backend.config import settings from backend.db.database import get_db from backend.db.models import BacktestCase, BacktestRun +from backend.i18n import tr router = APIRouter(prefix="/backtest", tags=["backtest"]) admin = [Depends(require_admin)] @@ -78,7 +79,7 @@ async def start_run(body: RunIn): async def get_run(run_id: uuid.UUID, db: AsyncSession = Depends(get_db)): run = await db.get(BacktestRun, run_id) if run is None: - raise HTTPException(status_code=404, detail="Backtest non trovato") + raise HTTPException(status_code=404, detail=tr("Backtest non trovato", "Backtest not found")) return _run_out(run) @@ -94,14 +95,14 @@ async def get_cases(run_id: uuid.UUID, status: Literal["ok", "skipped", "all"] = @router.post("/runs/{run_id}/stop", dependencies=admin) async def stop_run(run_id: uuid.UUID): if engine._run_id != run_id or not engine.stop(): - raise HTTPException(status_code=409, detail="Questo backtest non è in corso") + raise HTTPException(status_code=409, detail=tr("Questo backtest non è in corso", "This backtest is not running")) return {"status": "stopping"} @router.delete("/runs/{run_id}", status_code=204, dependencies=admin) async def delete_run(run_id: uuid.UUID, db: AsyncSession = Depends(get_db)): if engine.is_running() and engine._run_id == run_id: - raise HTTPException(status_code=409, detail="Ferma prima il backtest in corso") + raise HTTPException(status_code=409, detail=tr("Ferma prima il backtest in corso", "Stop the running backtest first")) await db.execute(delete(BacktestRun).where(BacktestRun.id == run_id)) await db.commit() diff --git a/backend/api/routes/markets.py b/backend/api/routes/markets.py index 6a47fbe..3ab3393 100644 --- a/backend/api/routes/markets.py +++ b/backend/api/routes/markets.py @@ -17,6 +17,7 @@ from backend.markets import bulk, polymarket, targeted from backend.markets.forecast import brier_score from backend.markets.service import get_market_evidence, predict_market, refresh_links, run_market_pipeline +from backend.i18n import tr router = APIRouter(prefix="/markets", tags=["markets"]) predictions_router = APIRouter(prefix="/predictions", tags=["predictions"]) @@ -89,13 +90,13 @@ async def predict_all_status(db: AsyncSession = Depends(get_db)): @router.post("/predict-all", status_code=202, dependencies=[Depends(require_admin)]) -async def predict_all_start(only_new: bool = Query(False, description="Solo mercati mai valutati o con notizie nuove dall'ultima previsione"), - refresh_first: bool = Query(True, description="Aggiorna prezzi e collegamenti prima di valutare")): +async def predict_all_start(only_new: bool = Query(False, description="Only markets never assessed or with news newer than the latest forecast"), + refresh_first: bool = Query(True, description="Refresh prices and links before assessing")): """Starts a Jev forecast on every open market with recent news (one paid API call per market).""" if not jev.is_enabled(): - raise HTTPException(status_code=503, detail="TYPESAFE_API_KEY non è configurata") + raise HTTPException(status_code=503, detail=tr("TYPESAFE_API_KEY non è configurata", "TYPESAFE_API_KEY is not configured")) if not bulk.start(only_new=only_new, refresh_first=refresh_first): - raise HTTPException(status_code=409, detail="Una valutazione di tutti i mercati è già in corso") + raise HTTPException(status_code=409, detail=tr("Una valutazione di tutti i mercati è già in corso", "An assessment of all markets is already running")) return bulk.snapshot() @@ -108,12 +109,12 @@ async def predict_all_stop(): @router.get("", response_model=MarketListResponse) async def list_markets( - q: Optional[str] = Query(None, description="Filtra per testo nella domanda del mercato"), - only_linked: bool = Query(False, description="Solo mercati con notizie collegate"), + q: Optional[str] = Query(None, description="Filter by text in the market question"), + only_linked: bool = Query(False, description="Only markets with linked news"), include_closed: bool = Query(False), sort: Literal["volume", "end_date", "price", "signal", "edge", "news", "liquidity", "question"] = Query( - "volume", description="Ordinamento: volume, scadenza, prezzo SÌ, ultimo segnale, edge, notizie collegate, liquidità, domanda"), - order: Optional[Literal["asc", "desc"]] = Query(None, description="Direzione; default sensato per ogni campo"), + "volume", description="Sorting: volume, end date, YES price, latest signal, edge, linked news, liquidity, question"), + order: Optional[Literal["asc", "desc"]] = Query(None, description="Direction; a sensible default for each field"), limit: int = Query(50, ge=1, le=200), offset: int = Query(0, ge=0), db: AsyncSession = Depends(get_db), @@ -165,7 +166,7 @@ async def list_markets( async def get_market(market_id: str, db: AsyncSession = Depends(get_db)): market = await db.get(Market, market_id) if not market: - raise HTTPException(status_code=404, detail="Market not found") + raise HTTPException(status_code=404, detail=tr("Mercato non trovato", "Market not found")) evidence = await get_market_evidence(db, market_id, limit=50) predictions = (await db.execute( select(MarketPrediction).where(MarketPrediction.market_id == market_id) @@ -204,9 +205,9 @@ async def predict(market_id: str, refresh_price: bool = Query(True), db: AsyncSe """Runs a Jev forecast now (costs one TypeSafe API call).""" market = await db.get(Market, market_id) if not market: - raise HTTPException(status_code=404, detail="Market not found") + raise HTTPException(status_code=404, detail=tr("Mercato non trovato", "Market not found")) if not jev.is_enabled(): - raise HTTPException(status_code=503, detail="TYPESAFE_API_KEY is not configured") + raise HTTPException(status_code=503, detail=tr("TYPESAFE_API_KEY non è configurata", "TYPESAFE_API_KEY is not configured")) if refresh_price: # Edge is only meaningful against the current price try: @@ -217,7 +218,7 @@ async def predict(market_id: str, refresh_price: bool = Query(True), db: AsyncSe except Exception: pass # fall back to the last synced price if market.closed: - raise HTTPException(status_code=409, detail="Market is closed") + raise HTTPException(status_code=409, detail=tr("Il mercato è chiuso", "Market is closed")) try: # Interactive request: do not keep the user waiting behind the background queue with feature("previsioni"): @@ -225,7 +226,8 @@ async def predict(market_id: str, refresh_price: bool = Query(True), db: AsyncSe except BudgetExceeded as e: raise HTTPException(status_code=429, detail=str(e)) except RateLimited as e: - raise HTTPException(status_code=429, detail=f"Jev ha raggiunto il limite di richieste: riprova tra {max(1, round(e.retry_in))} secondi.", + raise HTTPException(status_code=429, detail=tr(f"Jev ha raggiunto il limite di richieste: riprova tra {max(1, round(e.retry_in))} secondi.", + f"Jev has hit its request limit: try again in {max(1, round(e.retry_in))} seconds."), headers={"Retry-After": str(max(1, round(e.retry_in)))}) except LookupError as e: raise HTTPException(status_code=422, detail=str(e)) @@ -239,14 +241,14 @@ async def search_news(market_id: str, db: AsyncSession = Depends(get_db)): """Searches news about this market now (Google News) and links what matches.""" market = await db.get(Market, market_id) if not market: - raise HTTPException(status_code=404, detail="Market not found") + raise HTTPException(status_code=404, detail=tr("Mercato non trovato", "Market not found")) if not settings.TARGETED_NEWS_ENABLED: - raise HTTPException(status_code=503, detail="La ricerca mirata è disattivata (TARGETED_NEWS_ENABLED=false)") + raise HTTPException(status_code=503, detail=tr("La ricerca mirata è disattivata (TARGETED_NEWS_ENABLED=false)", "Targeted search is disabled (TARGETED_NEWS_ENABLED=false)")) try: added = await targeted.search_market(db, market) except Exception as e: await db.rollback() - raise HTTPException(status_code=502, detail=f"Ricerca non riuscita: {e}") + raise HTTPException(status_code=502, detail=tr(f"Ricerca non riuscita: {e}", f"Search failed: {e}")) await refresh_links(db, market_ids=[market_id]) linked = (await db.execute( select(func.count()).select_from(MarketArticleLink).where(MarketArticleLink.market_id == market_id) diff --git a/backend/api/routes/multi.py b/backend/api/routes/multi.py index 3664425..b5b74d2 100644 --- a/backend/api/routes/multi.py +++ b/backend/api/routes/multi.py @@ -9,6 +9,7 @@ from backend.db.database import get_db from backend.db.models import MultiArticleLink, MultiEvent, MultiOutcome, MultiPrediction from backend.multi import service +from backend.i18n import tr router = APIRouter(prefix="/multi", tags=["multi"]) @@ -116,7 +117,7 @@ async def opportunities( async def get_event(event_id: str, db: AsyncSession = Depends(get_db)): event = await db.get(MultiEvent, event_id) if event is None: - raise HTTPException(status_code=404, detail="Evento non trovato") + raise HTTPException(status_code=404, detail=tr("Evento non trovato", "Event not found")) outcomes = await service.outcomes_of(db, event_id) preds = (await db.execute(select(MultiPrediction).where(MultiPrediction.event_id == event_id) .order_by(MultiPrediction.created_at.desc()).limit(30))).scalars().all() @@ -139,18 +140,19 @@ async def get_event(event_id: str, db: AsyncSession = Depends(get_db)): async def predict(event_id: str, db: AsyncSession = Depends(get_db)): event = await db.get(MultiEvent, event_id) if event is None: - raise HTTPException(status_code=404, detail="Evento non trovato") + raise HTTPException(status_code=404, detail=tr("Evento non trovato", "Event not found")) if not jev.is_enabled(): - raise HTTPException(status_code=503, detail="TYPESAFE_API_KEY non è configurata") + raise HTTPException(status_code=503, detail=tr("TYPESAFE_API_KEY non è configurata", "TYPESAFE_API_KEY is not configured")) if event.closed: - raise HTTPException(status_code=409, detail="L'evento è chiuso") + raise HTTPException(status_code=409, detail=tr("L'evento è chiuso", "The event is closed")) try: with feature("piu_esiti"): return _prediction_out(await service.predict_event(db, event, max_wait=10)) except BudgetExceeded as e: raise HTTPException(status_code=429, detail=str(e)) except RateLimited as e: - raise HTTPException(status_code=429, detail=f"Jev ha raggiunto il limite di richieste: riprova tra {max(1, round(e.retry_in))} secondi.") + raise HTTPException(status_code=429, detail=tr(f"Jev ha raggiunto il limite di richieste: riprova tra {max(1, round(e.retry_in))} secondi.", + f"Jev has hit its request limit: try again in {max(1, round(e.retry_in))} seconds.")) except LookupError as e: raise HTTPException(status_code=422, detail=str(e)) except ValueError as e: diff --git a/backend/api/routes/portfolio.py b/backend/api/routes/portfolio.py index 9d4d328..28d200b 100644 --- a/backend/api/routes/portfolio.py +++ b/backend/api/routes/portfolio.py @@ -13,6 +13,7 @@ from backend.config import settings from backend.db.database import get_db from backend.db.models import Market, MarketPrediction, PaperBet, PaperExclusion +from backend.i18n import tr router = APIRouter(prefix="/portfolio", tags=["portfolio"]) admin = [Depends(require_admin)] @@ -87,12 +88,12 @@ async def sell_bet_now(bet_id: uuid.UUID, db: AsyncSession = Depends(get_db)): """Sells an open simulated bet now, at the best bids of the order book.""" bet = await _bet(db, bet_id) if bet.status != "open": - raise HTTPException(status_code=409, detail="La scommessa non è aperta") + raise HTTPException(status_code=409, detail=tr("La scommessa non è aperta", "The bet is not open")) market = await db.get(Market, bet.market_id) if market.closed: - raise HTTPException(status_code=409, detail="Il mercato è chiuso: si attende la risoluzione") + raise HTTPException(status_code=409, detail=tr("Il mercato è chiuso: si attende la risoluzione", "The market is closed: waiting for resolution")) if not await portfolio.sell_bet(db, bet, market, "Venduta a mano"): - raise HTTPException(status_code=409, detail="Il book non ha abbastanza offerte di acquisto per vendere tutte le quote") + raise HTTPException(status_code=409, detail=tr("Il book non ha abbastanza offerte di acquisto per vendere tutte le quote", "The book does not have enough bids to sell all the shares")) return _bet_dict(bet, market) @@ -125,7 +126,7 @@ async def list_bets(status: Literal["open", "settled", "excluded", "all"] = Quer async def _bet(db: AsyncSession, bet_id: uuid.UUID) -> PaperBet: bet = await db.get(PaperBet, bet_id) if bet is None: - raise HTTPException(status_code=404, detail="Scommessa non trovata") + raise HTTPException(status_code=404, detail=tr("Scommessa non trovata", "Bet not found")) return bet @@ -146,7 +147,7 @@ async def include_bet(bet_id: uuid.UUID, db: AsyncSession = Depends(get_db)): other_open = (await db.execute(select(PaperBet.id).where( PaperBet.market_id == bet.market_id, PaperBet.status == "open", PaperBet.id != bet.id))).first() if other_open: - raise HTTPException(status_code=409, detail="C'è già una scommessa aperta su questo mercato") + raise HTTPException(status_code=409, detail=tr("C'è già una scommessa aperta su questo mercato", "There is already an open bet on this market")) bet.status = "open" await db.commit() await portfolio.settle_bets(db) # settles it right away if the market has resolved @@ -170,7 +171,7 @@ async def add_exclusion(body: ExclusionIn, db: AsyncSession = Depends(get_db)): async def remove_exclusion(exclusion_id: uuid.UUID, db: AsyncSession = Depends(get_db)): row = await db.get(PaperExclusion, exclusion_id) if row is None: - raise HTTPException(status_code=404, detail="Esclusione non trovata") + raise HTTPException(status_code=404, detail=tr("Esclusione non trovata", "Exclusion not found")) await db.delete(row) await db.commit() @@ -183,7 +184,7 @@ async def remove_exclusion(exclusion_id: uuid.UUID, db: AsyncSession = Depends(g async def _latest(db: AsyncSession, market_id: str): market = await db.get(Market, market_id) if market is None: - raise HTTPException(status_code=404, detail="Mercato non trovato") + raise HTTPException(status_code=404, detail=tr("Mercato non trovato", "Market not found")) if market.multi_event_id: # Outcome of a multi-outcome event: its share of the latest distribution forecast from backend.db.models import MultiPrediction @@ -192,12 +193,12 @@ async def _latest(db: AsyncSession, market_id: str): .order_by(MultiPrediction.created_at.desc()).limit(1))).scalar_one_or_none() prediction = outcome_prediction(latest, market_id) if latest else None if prediction is None: - raise HTTPException(status_code=409, detail="L'evento non ha ancora una previsione per questo esito") + raise HTTPException(status_code=409, detail=tr("L'evento non ha ancora una previsione per questo esito", "The event has no forecast for this outcome yet")) return market, prediction prediction = (await db.execute(select(MarketPrediction).where(MarketPrediction.market_id == market_id) .order_by(MarketPrediction.created_at.desc()).limit(1))).scalar_one_or_none() if prediction is None: - raise HTTPException(status_code=409, detail="Il mercato non ha ancora una previsione") + raise HTTPException(status_code=409, detail=tr("Il mercato non ha ancora una previsione", "The market has no forecast yet")) return market, prediction @@ -234,8 +235,8 @@ async def place_manual_bet(market_id: str, db: AsyncSession = Depends(get_db)): ev = await portfolio.evaluate_prediction(db, market, prediction) if ev.verdict not in ("GO", "SMALL"): reasons = "; ".join(r["text"] for r in ev.as_dict()["reasons"] if r["blocking"]) - raise HTTPException(status_code=409, detail=f"Non conviene: {reasons}") + raise HTTPException(status_code=409, detail=tr(f"Non conviene: {reasons}", f"Not worth it: {reasons}")) bet = await portfolio.maybe_place_bet(db, market, prediction, ev, placed_by="manual") if bet is None: - raise HTTPException(status_code=409, detail="C'è già una scommessa aperta su questo mercato") + raise HTTPException(status_code=409, detail=tr("C'è già una scommessa aperta su questo mercato", "There is already an open bet on this market")) return _bet_dict(bet, market) diff --git a/backend/api/routes/sources.py b/backend/api/routes/sources.py index fed74b0..cf330de 100644 --- a/backend/api/routes/sources.py +++ b/backend/api/routes/sources.py @@ -9,6 +9,7 @@ from backend.db.database import get_db from backend.db.models import Article, ProcessedArticle, Source from backend.ingestor import fetcher +from backend.i18n import lang, tr from backend.ingestor.sources import ( CATEGORIES, get_by_url, load_catalog, normalize_feed_url, validate_category, validate_name, ) @@ -94,7 +95,7 @@ async def _stats(db: AsyncSession, ids: list) -> dict: async def _get(db: AsyncSession, source_id: uuid.UUID) -> Source: source = await db.get(Source, source_id) if source is None or source.kind != "feed": # the targeted-search source is managed automatically - raise HTTPException(status_code=404, detail="Fonte non trovata") + raise HTTPException(status_code=404, detail=tr("Fonte non trovata", "Source not found")) return source @@ -113,7 +114,9 @@ async def list_sources(db: AsyncSession = Depends(get_db)): async def catalog(db: AsyncSession = Depends(get_db)): """Recommended feeds from feeds.yaml, marking those already added.""" existing = set((await db.execute(select(Source.url))).scalars().all()) - return [{**{k: f[k] for k in ("name", "url", "category_hint", "description")}, "added": f["url"] in existing} + return [{**{k: f[k] for k in ("name", "url", "category_hint")}, + "description": (f.get("description_it") if lang() == "it" else None) or f["description"], + "added": f["url"] in existing} for f in load_catalog()] @@ -129,7 +132,7 @@ async def add_from_catalog(body: CatalogAddIn, db: AsyncSession = Depends(get_db for url in dict.fromkeys(body.urls): feed = by_url.get(url) if feed is None: - raise HTTPException(status_code=422, detail=f"{url} non è nel catalogo") + raise HTTPException(status_code=422, detail=tr(f"{url} non è nel catalogo", f"{url} is not in the catalogue")) if await get_by_url(db, url): continue source = Source(name=feed["name"], url=url, category_hint=feed["category_hint"], active=True) @@ -149,7 +152,7 @@ async def test_feed(body: FeedTestIn): except fetcher.FeedError as e: return {"ok": False, "error": str(e)} if not result.entries: - return {"ok": False, "title": result.title, "error": "Il feed è valido ma non contiene notizie"} + return {"ok": False, "title": result.title, "error": tr("Il feed è valido ma non contiene notizie", "The feed is valid but contains no news")} return {"ok": True, "title": result.title, "item_count": len(result.entries), "samples": [e["title"] for e in result.entries[:3]]} @@ -163,7 +166,7 @@ async def create_source(body: SourceIn, db: AsyncSession = Depends(get_db)): except ValueError as e: raise _bad_request(e) if await get_by_url(db, url): - raise HTTPException(status_code=409, detail="Questa fonte è già presente") + raise HTTPException(status_code=409, detail=tr("Questa fonte è già presente", "This source is already there")) source = Source(name=name, url=url, category_hint=category, active=body.active) db.add(source) await db.commit() @@ -181,7 +184,7 @@ async def update_source(source_id: uuid.UUID, body: SourcePatch, db: AsyncSessio url = normalize_feed_url(fields["url"]) other = await get_by_url(db, url) if other is not None and other.id != source.id: - raise HTTPException(status_code=409, detail="Un'altra fonte usa già questo indirizzo") + raise HTTPException(status_code=409, detail=tr("Un'altra fonte usa già questo indirizzo", "Another source already uses this address")) if url != source.url: source.url = url source.last_status = source.last_error = source.last_fetched_at = source.last_new_items = None @@ -202,7 +205,8 @@ async def delete_source(source_id: uuid.UUID, delete_articles: bool = Query(Fals source = await _get(db, source_id) count = (await db.execute(select(func.count(Article.id)).where(Article.source_id == source_id))).scalar() or 0 if count and not delete_articles: - raise HTTPException(status_code=409, detail=f"La fonte ha {count} notizie: conferma per eliminarle insieme alla fonte, oppure disattivala.") + raise HTTPException(status_code=409, detail=tr(f"La fonte ha {count} notizie: conferma per eliminarle insieme alla fonte, oppure disattivala.", + f"The source has {count} news items: confirm to delete them together with the source, or deactivate it.")) if count: article_ids = select(Article.id).where(Article.source_id == source_id) await db.execute(delete(ProcessedArticle).where(ProcessedArticle.article_id.in_(article_ids))) diff --git a/backend/auth/cli.py b/backend/auth/cli.py index 269343f..438f951 100644 --- a/backend/auth/cli.py +++ b/backend/auth/cli.py @@ -15,12 +15,13 @@ from backend.auth import service from backend.db.database import SessionLocal, engine, init_db from backend.db.models import User +from backend.i18n import tr def _ask_password() -> str: - first = getpass.getpass("Password (minimo 12 caratteri): ") - if first != getpass.getpass("Ripeti la password: "): - sys.exit("Le password non coincidono.") + first = getpass.getpass(tr("Password (minimo 12 caratteri): ", "Password (at least 12 characters): ")) + if first != getpass.getpass(tr("Ripeti la password: ", "Repeat the password: ")): + sys.exit(tr("Le password non coincidono.", "The passwords do not match.")) return first @@ -30,33 +31,33 @@ async def _run(args) -> None: async with SessionLocal() as db: if args.command == "create-user": user = await service.create_user(db, args.username, _ask_password(), role=args.role) - print(f"Creato {user.username} ({user.role}).") + print(tr(f"Creato {user.username} ({user.role}).", f"Created {user.username} ({user.role}).")) elif args.command == "list-users": users = (await db.execute(select(User).order_by(User.username))).scalars().all() for u in users: - last = u.last_login_at.isoformat(timespec="minutes") if u.last_login_at else "mai" - print(f"{u.username:<32} {u.role:<7} {'attivo' if u.is_active else 'disattivato':<12} ultimo accesso: {last}") + last = u.last_login_at.isoformat(timespec="minutes") if u.last_login_at else tr("mai", "never") + print(f"{u.username:<32} {u.role:<7} {tr('attivo', 'active') if u.is_active else tr('disattivato', 'disabled'):<12} " + tr('ultimo accesso', 'last sign-in') + f": {last}") if not users: - print("Nessun utente.") + print(tr("Nessun utente.", "No users.")) else: user = await service.get_user(db, args.username) if user is None: - sys.exit(f"Utente {args.username} non trovato.") + sys.exit(tr(f"Utente {args.username} non trovato.", f"User {args.username} not found.")) if args.command == "set-password": await service.set_password(db, user, _ask_password()) - print("Password aggiornata. Tutte le sessioni dell'utente sono state chiuse.") + print(tr("Password aggiornata. Tutte le sessioni dell'utente sono state chiuse.", "Password updated. All the user's sessions have been closed.")) elif args.command == "disable": user.is_active = False await db.commit() await service.revoke_user_sessions(db, user) - print(f"{user.username} disattivato e disconnesso.") + print(tr(f"{user.username} disattivato e disconnesso.", f"{user.username} disabled and signed out.")) elif args.command == "enable": user.is_active = True await db.commit() - print(f"{user.username} riattivato.") + print(tr(f"{user.username} riattivato.", f"{user.username} enabled again.")) elif args.command == "revoke-sessions": n = await service.revoke_user_sessions(db, user) - print(f"Chiuse {n} sessioni.") + print(tr(f"Chiuse {n} sessioni.", f"Closed {n} sessions.")) except ValueError as e: sys.exit(str(e)) finally: @@ -64,14 +65,14 @@ async def _run(args) -> None: def main() -> None: - parser = argparse.ArgumentParser(prog="python -m backend.auth.cli", description="Gestione utenti della dashboard") + parser = argparse.ArgumentParser(prog="python -m backend.auth.cli", description="Dashboard user management") sub = parser.add_subparsers(dest="command", required=True) - create = sub.add_parser("create-user", help="crea un utente (chiede la password)") + create = sub.add_parser("create-user", help=tr("crea un utente (chiede la password)", "create a user (asks for the password)")) create.add_argument("username") create.add_argument("--role", choices=service.ROLES, default="viewer") - sub.add_parser("list-users", help="elenca gli utenti") - for name, help_text in (("set-password", "imposta una nuova password"), ("disable", "disattiva e disconnette"), - ("enable", "riattiva"), ("revoke-sessions", "chiude tutte le sessioni")): + sub.add_parser("list-users", help=tr("elenca gli utenti", "list the users")) + for name, help_text in (("set-password", tr("imposta una nuova password", "set a new password")), ("disable", tr("disattiva e disconnette", "disable and sign out")), + ("enable", tr("riattiva", "enable again")), ("revoke-sessions", tr("chiude tutte le sessioni", "close all sessions"))): sub.add_parser(name, help=help_text).add_argument("username") asyncio.run(_run(parser.parse_args())) diff --git a/backend/auth/deps.py b/backend/auth/deps.py index 4090f03..7a0266f 100644 --- a/backend/auth/deps.py +++ b/backend/auth/deps.py @@ -7,6 +7,7 @@ from backend.auth.service import ActiveSession, resolve_session from backend.config import settings from backend.db.database import get_db +from backend.i18n import tr COOKIE_NAME = "nm_session" CSRF_HEADER = "X-CSRF-Token" @@ -60,16 +61,16 @@ async def require_user(request: Request, db: AsyncSession = Depends(get_db)) -> """Any signed-in user. Unsafe methods must also carry the session's CSRF token.""" active = await resolve_session(db, session_token(request)) if active is None: - raise HTTPException(status_code=401, detail="Accesso richiesto") + raise HTTPException(status_code=401, detail=tr("Accesso richiesto", "Sign-in required")) if request.method not in SAFE_METHODS: sent = request.headers.get(CSRF_HEADER, "") if not sent or not hmac.compare_digest(sent, active.session.csrf_token): - raise HTTPException(status_code=403, detail="Token CSRF mancante o non valido. Ricarica la pagina.") + raise HTTPException(status_code=403, detail=tr("Token CSRF mancante o non valido. Ricarica la pagina.", "Missing or invalid CSRF token. Reload the page.")) request.state.auth = active return active async def require_admin(active: ActiveSession = Depends(require_user)) -> ActiveSession: if active.user.role != "admin": - raise HTTPException(status_code=403, detail="Serve un account amministratore per questa operazione.") + raise HTTPException(status_code=403, detail=tr("Serve un account amministratore per questa operazione.", "An administrator account is needed for this operation.")) return active diff --git a/backend/auth/passwords.py b/backend/auth/passwords.py index 02b1940..67e8bf8 100644 --- a/backend/auth/passwords.py +++ b/backend/auth/passwords.py @@ -1,5 +1,6 @@ """Argon2id password hashing (argon2-cffi defaults follow the RFC 9106 recommendations).""" import re +from backend.i18n import tr from functools import lru_cache from argon2 import PasswordHasher from argon2.exceptions import InvalidHashError, VerificationError, VerifyMismatchError @@ -18,17 +19,17 @@ def normalize_username(username: str) -> str: def validate_username(username: str) -> str: username = normalize_username(username) if not USERNAME_RE.match(username): - raise ValueError("Lo username deve avere 3-32 caratteri: lettere, numeri, punto, trattino o underscore.") + raise ValueError(tr("Lo username deve avere 3-32 caratteri: lettere, numeri, punto, trattino o underscore.", "The username must have 3-32 characters: letters, numbers, dot, hyphen or underscore.")) return username def validate_password(password: str, username: str = "") -> None: if len(password) < MIN_PASSWORD_LENGTH: - raise ValueError(f"La password deve avere almeno {MIN_PASSWORD_LENGTH} caratteri.") + raise ValueError(tr(f"La password deve avere almeno {MIN_PASSWORD_LENGTH} caratteri.", f"The password must have at least {MIN_PASSWORD_LENGTH} characters.")) if len(password) > MAX_PASSWORD_LENGTH: - raise ValueError(f"La password può avere al massimo {MAX_PASSWORD_LENGTH} caratteri.") + raise ValueError(tr(f"La password può avere al massimo {MAX_PASSWORD_LENGTH} caratteri.", f"The password can have at most {MAX_PASSWORD_LENGTH} characters.")) if username and username.lower() in password.lower(): - raise ValueError("La password non può contenere lo username.") + raise ValueError(tr("La password non può contenere lo username.", "The password cannot contain the username.")) def hash_password(password: str) -> str: diff --git a/backend/auth/service.py b/backend/auth/service.py index 1b14400..b23f873 100644 --- a/backend/auth/service.py +++ b/backend/auth/service.py @@ -13,6 +13,7 @@ ) from backend.config import settings from backend.db.models import User, UserSession +from backend.i18n import tr logger = logging.getLogger("backend.auth") @@ -42,10 +43,10 @@ async def get_user(db: AsyncSession, username: str) -> Optional[User]: async def create_user(db: AsyncSession, username: str, password: str, role: str = "viewer") -> User: username = validate_username(username) if role not in ROLES: - raise ValueError(f"Ruolo non valido: usa {' o '.join(ROLES)}.") + raise ValueError(tr(f"Ruolo non valido: usa {' o '.join(ROLES)}.", f"Invalid role: use {' or '.join(ROLES)}.")) validate_password(password, username) if await get_user(db, username): - raise ValueError(f"L'utente {username} esiste già.") + raise ValueError(tr(f"L'utente {username} esiste già.", f"User {username} already exists.")) user = User(username=username, password_hash=hash_password(password), role=role) db.add(user) await db.commit() @@ -125,9 +126,9 @@ async def revoke_user_sessions(db: AsyncSession, user: User, keep_token_hash: Op async def change_password(db: AsyncSession, user: User, current: str, new: str, keep_token_hash: Optional[str] = None) -> None: if not verify_password(user.password_hash, current): - raise PermissionError("La password attuale non è corretta.") + raise PermissionError(tr("La password attuale non è corretta.", "The current password is not correct.")) if current == new: - raise ValueError("La nuova password deve essere diversa da quella attuale.") + raise ValueError(tr("La nuova password deve essere diversa da quella attuale.", "The new password must be different from the current one.")) await set_password(db, user, new, keep_token_hash=keep_token_hash) diff --git a/backend/backtest/engine.py b/backend/backtest/engine.py index cf3b154..2917d99 100644 --- a/backend/backtest/engine.py +++ b/backend/backtest/engine.py @@ -35,6 +35,7 @@ from backend.backtest.analysis import summarize from backend.betting.fees import category_rate from backend.betting.economics import Exposure, estimated_quote, evaluate +from backend.i18n import tr from backend.betting.profiles import get_profile from backend.config import settings from backend.db.database import SessionLocal @@ -72,20 +73,20 @@ class Params: def validate(self) -> "Params": self.kinds = [k for k in dict.fromkeys(self.kinds) if k in ("binary", "multi")] if not self.kinds: - raise ValueError("Scegli almeno un tipo di mercato") + raise ValueError(tr("Scegli almeno un tipo di mercato", "Choose at least one market type")) if self.news_source not in NEWS_SOURCES: - raise ValueError("Fonte delle notizie non valida") + raise ValueError(tr("Fonte delle notizie non valida", "Invalid news source")) if self.resolved_after >= self.resolved_before: - raise ValueError("La data di inizio deve essere prima della data di fine") + raise ValueError(tr("La data di inizio deve essere prima della data di fine", "The start date must be before the end date")) if self.resolved_before > datetime.now(timezone.utc) + timedelta(days=1): - raise ValueError("La data di fine non può essere nel futuro") + raise ValueError(tr("La data di fine non può essere nel futuro", "The end date cannot be in the future")) if not 1 <= self.max_markets <= 300: - raise ValueError("Il numero di mercati deve essere tra 1 e 300") + raise ValueError(tr("Il numero di mercati deve essere tra 1 e 300", "The number of markets must be between 1 and 300")) if not 0 < self.max_calls <= 1000: - raise ValueError("Il limite di chiamate deve essere tra 1 e 1000") + raise ValueError(tr("Il limite di chiamate deve essere tra 1 e 1000", "The call limit must be between 1 and 1000")) self.horizons = sorted({int(h) for h in self.horizons}) if not self.horizons or any(h < 1 or h > 180 for h in self.horizons): - raise ValueError("Gli orizzonti vanno da 1 a 180 giorni") + raise ValueError(tr("Gli orizzonti vanno da 1 a 180 giorni", "Horizons go from 1 to 180 days")) return self def as_json(self) -> dict: @@ -107,12 +108,12 @@ def is_running() -> bool: async def start(params: Params) -> BacktestRun: global _task, _cancel, _run_id if is_running(): - raise RuntimeError("Un backtest è già in corso") + raise RuntimeError(tr("Un backtest è già in corso", "A backtest is already running")) if not jev.is_enabled(): - raise jev.JevUnavailableError("TYPESAFE_API_KEY non è configurata") + raise jev.JevUnavailableError(tr("TYPESAFE_API_KEY non è configurata", "TYPESAFE_API_KEY is not configured")) params.validate() async with SessionLocal() as db: - run = BacktestRun(params=params.as_json(), status="running", message="Ricerca dei mercati risolti…") + run = BacktestRun(params=params.as_json(), status="running", message=tr("Ricerca dei mercati risolti…", "Looking for resolved markets…")) db.add(run) await db.commit() await db.refresh(run) @@ -138,7 +139,7 @@ async def recover_interrupted() -> None: """A restart kills the task: mark runs left 'running' as stopped.""" async with SessionLocal() as db: for run in (await db.execute(select(BacktestRun).where(BacktestRun.status == "running"))).scalars().all(): - run.status, run.message = "stopped", "Interrotto dal riavvio del server" + run.status, run.message = "stopped", tr("Interrotto dal riavvio del server", "Stopped by the server restart") run.finished_at = datetime.now(timezone.utc) await db.commit() @@ -326,7 +327,7 @@ async def _run_tagged(run_id, params: Params) -> None: events = await polymarket.fetch_resolved_events(params.resolved_after, params.resolved_before, limit=params.max_markets, min_volume=params.min_volume) plan += [("multi", e, h, a) for e, h, a in plan_event_cases(events, params.horizons)] - await _update(run_id, total=len(plan), message=None if plan else "Nessun mercato risolto con questi filtri") + await _update(run_id, total=len(plan), message=None if plan else tr("Nessun mercato risolto con questi filtri", "No resolved market with these filters")) done = skipped = failed = calls = consecutive = 0 histories: dict = {} for kind, item, horizon, as_of in plan: @@ -334,7 +335,7 @@ async def _run_tagged(run_id, params: Params) -> None: status, message = "stopped", "Interrotto" break if calls >= params.max_calls: - status, message = "done", f"Raggiunto il limite di {params.max_calls} chiamate a Jev" + status, message = "done", tr(f"Raggiunto il limite di {params.max_calls} chiamate a Jev", f"Reached the limit of {params.max_calls} Jev calls") break title = item.question if kind == "binary" else item.title case = BacktestCase(run_id=run_id, kind=kind, market_id=item.id, question=title, url=item.url, @@ -350,7 +351,7 @@ async def _run_tagged(run_id, params: Params) -> None: break except RateLimited as e: status = "stopped" - message = f"Fermato: {e}" if isinstance(e, BudgetExceeded) else "Fermato: Jev continua a rifiutare le richieste per limite di frequenza" + message = tr(f"Fermato: {e}", f"Stopped: {e}") if isinstance(e, BudgetExceeded) else tr("Fermato: Jev continua a rifiutare le richieste per limite di frequenza", "Stopped: Jev keeps refusing requests because of the rate limit") break except Exception as e: logger.warning(f"Backtest case {item.id} ({horizon} gg) failed: {e}") @@ -364,7 +365,7 @@ async def _run_tagged(run_id, params: Params) -> None: failed += case.status == "error" await _update(run_id, done=done, skipped=skipped, failed=failed) if consecutive >= MAX_CONSECUTIVE_FAILURES: - status, message = "failed", "Fermato dopo errori ripetuti: controlla la connessione a Polymarket, Google News e TypeSafe" + status, message = "failed", tr("Fermato dopo errori ripetuti: controlla la connessione a Polymarket, Google News e TypeSafe", "Stopped after repeated errors: check the connection to Polymarket, Google News and TypeSafe") break except Exception as e: logger.exception("Backtest failed") @@ -388,17 +389,17 @@ async def _eval_binary(case, market, as_of, params, histories, preset) -> bool: market.yes_token_id, as_of - timedelta(days=max(params.horizons) + 2), market.end_date) price = polymarket.price_at(histories[market.id], as_of) if price is None: - case.status, case.note = "skipped", "Prezzo storico non disponibile" + case.status, case.note = "skipped", tr("Prezzo storico non disponibile", "Historical price not available") return False case.price = price if params.exclude_decided and not DECIDED_BELOW <= price <= DECIDED_ABOVE: - case.status, case.note = "skipped", "Esito già scontato dal prezzo" + case.status, case.note = "skipped", tr("Esito già scontato dal prezzo", "Outcome already priced in") return False evidence, source = await past_evidence(market.question, as_of, params.news_source) case.details = {"news_source": source} case.news_count = len(evidence) if not evidence: - case.status, case.note = "skipped", "Nessuna notizia di quei giorni" + case.status, case.note = "skipped", tr("Nessuna notizia di quei giorni", "No news from those days") return False ns_market = SimpleNamespace(question=market.question, description=market.description, end_date=market.end_date) state, questions = build_jev_request(ns_market, evidence, now=as_of) @@ -455,7 +456,7 @@ async def _eval_multi(case, event, as_of, params, histories, preset) -> bool: if p is not None: priced.append(SimpleNamespace(id=o.id, label=o.group_title, yes_price=p, closed=False)) if len(priced) < 3: - case.status, case.note = "skipped", "Prezzo storico non disponibile" + case.status, case.note = "skipped", tr("Prezzo storico non disponibile", "Historical price not available") return False priced.sort(key=lambda o: -o.yes_price) items = multi.market_distribution(priced, settings.MULTI_MAX_OUTCOMES) @@ -464,13 +465,13 @@ async def _eval_multi(case, event, as_of, params, histories, preset) -> bool: # The winner may be outside the outcomes shown to Jev: then "other outcomes" won winner = event.winner_id if event.winner_id in listed else multi.OTHER_ID if winner == multi.OTHER_ID and not has_other: - case.status, case.note = "skipped", "Il vincitore non ha uno storico dei prezzi" + case.status, case.note = "skipped", tr("Il vincitore non ha uno storico dei prezzi", "The winner has no price history") return False evidence, source = await past_evidence(event.title, as_of, params.news_source, labels=[i["label"] for i in items if i["id"] != multi.OTHER_ID]) case.news_count = len(evidence) if not evidence: - case.status, case.note = "skipped", "Nessuna notizia di quei giorni" + case.status, case.note = "skipped", tr("Nessuna notizia di quei giorni", "No news from those days") return False ns_event = SimpleNamespace(title=event.title, description=event.description, end_date=event.end_date) state, questions, keys = multi.build_request(ns_event, items, evidence, now=as_of) diff --git a/backend/betting/economics.py b/backend/betting/economics.py index 1c0f0b6..aa78edf 100644 --- a/backend/betting/economics.py +++ b/backend/betting/economics.py @@ -9,14 +9,21 @@ from typing import Optional from backend.betting.fees import fee_per_share from backend.betting.profiles import RiskProfile +from backend.i18n import lang, tr MIN_ORDER_USD = 1.0 def _num(x: float, decimals: int = 0) -> str: - """Italian number format: 50.000 / 1,9.""" + """Number in the format of the language: 50.000 / 1,9 (it), 50,000 / 1.9 (en).""" text = f"{x:,.{decimals}f}" + if lang() != "it": + return text return text.replace(",", "\u2009").replace(".", ",").replace("\u2009", ".") + + +def _usd(x: float, decimals: int = 0) -> str: + return f"{_num(x, decimals)} $" if lang() == "it" else f"${_num(x, decimals)}" MAX_APR = 10.0 # 1000%: very short horizons make the annualized figure meaningless beyond this @@ -244,19 +251,24 @@ def evaluate( reasons: list[Reason] = [] notes: list[str] = [] if quote.source == "estimate": - notes.append("Book non disponibile: book stimato a 4 livelli, da prezzo medio + metà spread in su, con la profondità dalla liquidità dichiarata.") + notes.append(tr("Book non disponibile: book stimato a 4 livelli, da prezzo medio + metà spread in su, con la profondità dalla liquidità dichiarata.", + "Book not available: book estimated at 4 levels, from mid price + half spread upwards, with depth from the declared liquidity.")) if signal == "HOLD": - reasons.append(Reason("no_signal", "Il modello non vede una differenza sufficiente rispetto al prezzo.")) + reasons.append(Reason("no_signal", tr("Il modello non vede una differenza sufficiente rispetto al prezzo.", + "The model does not see a large enough difference from the price."))) if liquidity < profile.min_liquidity: - reasons.append(Reason("illiquid", f"Mercato poco liquido ({_num(liquidity)} $, il preset chiede almeno {_num(profile.min_liquidity)} $).")) + reasons.append(Reason("illiquid", tr(f"Mercato poco liquido ({_usd(liquidity)}, il preset chiede almeno {_usd(profile.min_liquidity)}).", + f"Illiquid market ({_usd(liquidity)}, the preset asks for at least {_usd(profile.min_liquidity)})."))) if days > profile.max_days: - reasons.append(Reason("too_far", f"Si risolve tra {days:.0f} giorni: il preset accetta al massimo {profile.max_days} giorni.")) + reasons.append(Reason("too_far", tr(f"Si risolve tra {days:.0f} giorni: il preset accetta al massimo {profile.max_days} giorni.", + f"It resolves in {days:.0f} days: the preset accepts at most {profile.max_days} days."))) if best is None: - reasons.append(Reason("no_book", "Nessuna offerta di vendita disponibile per questo lato.")) + reasons.append(Reason("no_book", tr("Nessuna offerta di vendita disponibile per questo lato.", "No sell offer available for this side."))) elif net_edge < profile.min_net_edge: reasons.append(Reason("edge_after_costs", - f"Dopo spread, commissioni e incertezza il margine è {_num(net_edge * 100, 1)} punti: ne servono almeno {_num(profile.min_net_edge * 100)}.")) + tr(f"Dopo spread, commissioni e incertezza il margine è {_num(net_edge * 100, 1)} punti: ne servono almeno {_num(profile.min_net_edge * 100)}.", + f"After spread, fees and uncertainty the margin is {_num(net_edge * 100, 1)} points: at least {_num(profile.min_net_edge * 100)} are needed."))) # Sizing caps = { @@ -276,12 +288,14 @@ def evaluate( if caps["market"] <= 0 or caps["event"] <= 0 or caps["category"] <= 0 or caps["total"] <= 0: reasons.append(Reason("exposure_cap", _cap_text(caps))) elif caps["cash"] <= 0: - reasons.append(Reason("no_cash", "Capitale disponibile esaurito: è tutto investito in posizioni aperte.")) + reasons.append(Reason("no_cash", tr("Capitale disponibile esaurito: è tutto investito in posizioni aperte.", + "No capital available: it is all invested in open positions."))) shares, spent, fee = fill(asks, stake, limit_price, quote.fee_bps) if stake > 0 else (0.0, 0.0, 0.0) min_order = max(MIN_ORDER_USD, (quote.min_order_shares or 0) * (best or 0)) if not [r for r in reasons if r.blocking] and spent < min_order: - reasons.append(Reason("stake_below_min", f"La puntata calcolata ({_num(spent, 2)} $) è sotto l'ordine minimo ({_num(min_order, 2)} $).")) + reasons.append(Reason("stake_below_min", tr(f"La puntata calcolata ({_usd(spent, 2)}) è sotto l'ordine minimo ({_usd(min_order, 2)}).", + f"The computed stake ({_usd(spent, 2)}) is below the minimum order ({_usd(min_order, 2)})."))) outlay = spent + fee avg_price = spent / shares if shares > 0 else None @@ -298,8 +312,10 @@ def evaluate( roi = apr = None if apr is not None and apr < hurdle and signal != "HOLD" and best is not None: reasons.append(Reason("return_too_low", - f"Rendimento annualizzato prudente {_num(apr * 100)}%, sotto la soglia del {_num(hurdle * 100)}% " - f"(tasso senza rischio {_num(risk_free_rate * 100)}% + premio del preset).")) + tr(f"Rendimento annualizzato prudente {_num(apr * 100)}%, sotto la soglia del {_num(hurdle * 100)}% " + f"(tasso senza rischio {_num(risk_free_rate * 100)}% + premio del preset).", + f"Prudent annualised return {_num(apr * 100)}%, below the {_num(hurdle * 100)}% threshold " + f"(risk-free rate {_num(risk_free_rate * 100)}% + preset premium)."))) blocking = [r for r in reasons if r.blocking] if blocking: @@ -325,16 +341,19 @@ def evaluate( ) -CAP_LABELS = { - "market": "limite per singolo mercato", "event": "limite per evento", "category": "limite per categoria", - "total": "limite di capitale investito", "cash": "capitale disponibile", "book": "profondità del book", -} +def cap_label(key: str) -> str: + return { + "market": tr("limite per singolo mercato", "per-market limit"), "event": tr("limite per evento", "per-event limit"), + "category": tr("limite per categoria", "per-category limit"), "total": tr("limite di capitale investito", "invested capital limit"), + "cash": tr("capitale disponibile", "available capital"), "book": tr("profondità del book", "book depth"), + }[key] def _cap_text(caps: dict) -> str: - full = [CAP_LABELS[k] for k in ("market", "event", "category", "total") if caps[k] <= 0] - return "Esposizione già al massimo: " + ", ".join(full) + "." + full = [cap_label(k) for k in ("market", "event", "category", "total") if caps[k] <= 0] + return tr("Esposizione già al massimo: ", "Exposure already at the maximum: ") + ", ".join(full) + "." def _limited_text(key: str) -> str: - return f"Puntata ridotta dal {CAP_LABELS[key]}: meno della metà di quella che Kelly suggerirebbe." + return tr(f"Puntata ridotta dal {cap_label(key)}: meno della metà di quella che Kelly suggerirebbe.", + f"Stake reduced by the {cap_label(key)}: less than half of what Kelly would suggest.") diff --git a/backend/betting/portfolio.py b/backend/betting/portfolio.py index 46e9d6f..05136ae 100644 --- a/backend/betting/portfolio.py +++ b/backend/betting/portfolio.py @@ -17,6 +17,7 @@ ) from backend.markets import polymarket from backend.markets.forecast import brier_score +from backend.i18n import tr logger = logging.getLogger(__name__) @@ -57,7 +58,7 @@ async def update_settings(db: AsyncSession, *, preset: Optional[str] = None, aut async def reset_portfolio(db: AsyncSession, bankroll: float, preset: Optional[str] = None) -> BettingSettings: """Starts over: deletes every simulated bet and sets a new initial capital.""" if not (10 <= bankroll <= 10_000_000): - raise ValueError("Il capitale deve essere tra 10 $ e 10.000.000 $") + raise ValueError(tr("Il capitale deve essere tra 10 $ e 10.000.000 $", "Capital must be between $10 and $10,000,000")) await db.execute(delete(PaperBet)) row = await get_settings(db) row.bankroll = float(bankroll) @@ -201,7 +202,7 @@ async def excluded_reason(db: AsyncSession, market: Market) -> Optional[str]: async def add_exclusion(db: AsyncSession, kind: str, value: str, label: Optional[str] = None) -> PaperExclusion: if kind not in EXCLUSION_KINDS or not value: - raise ValueError("Esclusione non valida") + raise ValueError(tr("Esclusione non valida", "Invalid exclusion")) existing = (await db.execute(select(PaperExclusion).where(PaperExclusion.kind == kind, PaperExclusion.value == value))).scalar_one_or_none() if existing: return existing diff --git a/backend/betting/profiles.py b/backend/betting/profiles.py index c6e9720..e02afd4 100644 --- a/backend/betting/profiles.py +++ b/backend/betting/profiles.py @@ -1,6 +1,8 @@ """Risk presets for the simulated portfolio.""" from dataclasses import dataclass, asdict +from backend.i18n import lang + @dataclass(frozen=True) class RiskProfile: @@ -20,7 +22,18 @@ class RiskProfile: max_days: int # skip markets resolving further away than this def as_dict(self) -> dict: - return asdict(self) + out = asdict(self) + if lang() != "it" and self.key in EN: + out["label"], out["description"] = EN[self.key] + return out + + +# The presets in English (the Italian text is in PROFILES) +EN = { + "prudente": ("Prudent", "Few, small bets, only with a wide margin and liquid markets that close within 4 months."), + "bilanciato": ("Balanced", "The default compromise: a quarter of Kelly, a 3-point margin after costs, at most 4% of capital per market."), + "aggressivo": ("Aggressive", "More and bigger bets: half Kelly, smaller margins, far-off markets too. Wide capital swings."), +} PROFILES = { diff --git a/backend/betting/strategy.py b/backend/betting/strategy.py index be88d23..1a85e14 100644 --- a/backend/betting/strategy.py +++ b/backend/betting/strategy.py @@ -17,23 +17,23 @@ from backend.betting.economics import annualize from backend.betting.fees import fee_per_share from backend.betting.profiles import RiskProfile +from backend.i18n import dec, dollars, side as side_label, tr from backend.markets.forecast import pool GRID = [round(0.01 + i * 0.005, 3) for i in range(197)] # YES prices from 1¢ to 99¢ # Reasons that no price can fix: the market itself does not suit the preset or the portfolio STRUCTURAL = {"illiquid", "too_far", "exposure_cap", "no_cash", "no_book"} -SIDE_IT = {"YES": "SÌ", "NO": "NO"} def _cents(p: Optional[float]) -> str: if p is None: return "–" - c = p * 100 - return f"{c:.1f}".replace(".", ",").removesuffix(",0") + "¢" + text = dec(p * 100, 1) + return text.removesuffix(",0").removesuffix(".0") + "¢" def _pts(x: float) -> str: - return f"{abs(x) * 100:.1f}".replace(".", ",") + " punti" + return dec(abs(x) * 100, 1) + tr(" punti", " points") def _pct(p: float) -> str: @@ -135,9 +135,12 @@ def _evidence_lines(tally: Optional[dict], side: str) -> tuple[list, list]: n = tally.get(key, 0) if n: titles = tally.get(f"{key}_titles") or [] - text = f"{n} {'notizia va' if n == 1 else 'notizie vanno'} in questa direzione" - if key == bad_key: - text = f"{n} {'notizia va' if n == 1 else 'notizie vanno'} nella direzione opposta" + if key == good_key: + text = (tr(f"{n} notizia va in questa direzione", f"{n} news item points this way") if n == 1 + else tr(f"{n} notizie vanno in questa direzione", f"{n} news items point this way")) + else: + text = (tr(f"{n} notizia va nella direzione opposta", f"{n} news item points the other way") if n == 1 + else tr(f"{n} notizie vanno nella direzione opposta", f"{n} news items point the other way")) if titles: text += ": «" + "», «".join(titles[:2]) + "»" out.append(text + ".") @@ -147,19 +150,25 @@ def _evidence_lines(tally: Optional[dict], side: str) -> tuple[list, list]: def _track_lines(track: Optional[dict]) -> tuple[list, list, int]: """How the forecasts did so far. Returns pros, cons and a confidence adjustment.""" if not track or not track.get("resolved_markets"): - return [], ["Nessun mercato risolto ancora: non si sa se le previsioni battano il prezzo."], -1 + return [], [tr("Nessun mercato risolto ancora: non si sa se le previsioni battano il prezzo.", + "No market resolved yet: it is not known whether the forecasts beat the price.")], -1 g = track.get("gain_blended") or {} n = track["resolved_markets"] if g.get("lo") is not None and g["lo"] > 0: - return [f"Su {n} mercati risolti le previsioni hanno battuto il prezzo, anche nel caso peggiore dell'intervallo."], [], 1 + return [tr(f"Su {n} mercati risolti le previsioni hanno battuto il prezzo, anche nel caso peggiore dell'intervallo.", + f"Over {n} resolved markets the forecasts beat the price, even in the worst case of the interval.")], [], 1 if g.get("hi") is not None and g["hi"] < 0: - return [], [f"Su {n} mercati risolti il prezzo è stato più accurato delle previsioni."], -2 + return [], [tr(f"Su {n} mercati risolti il prezzo è stato più accurato delle previsioni.", + f"Over {n} resolved markets the price was more accurate than the forecasts.")], -2 clv = track.get("signal_clv") or {} if clv.get("n", 0) >= 10 and clv.get("avg") is not None: if clv["avg"] > 0: - return [f"Finora, dopo i segnali, il prezzo si è mosso nella direzione giusta di {_pts(clv['avg'])} in media."], [], 0 - return [], [f"Finora, dopo i segnali, il prezzo si è mosso contro di {_pts(clv['avg'])} in media."], -1 - return [], [f"Solo {n} mercati risolti: non basta per dire se le previsioni battano il prezzo."], -1 + return [tr(f"Finora, dopo i segnali, il prezzo si è mosso nella direzione giusta di {_pts(clv['avg'])} in media.", + f"So far, after the signals, the price moved the right way by {_pts(clv['avg'])} on average.")], [], 0 + return [], [tr(f"Finora, dopo i segnali, il prezzo si è mosso contro di {_pts(clv['avg'])} in media.", + f"So far, after the signals, the price moved against them by {_pts(clv['avg'])} on average.")], -1 + return [], [tr(f"Solo {n} mercati risolti: non basta per dire se le previsioni battano il prezzo.", + f"Only {n} resolved markets: not enough to say whether the forecasts beat the price.")], -1 def confidence_of(evidence: float, sigma: float, estimated_book: bool, track_adj: int) -> tuple[str, list]: @@ -167,18 +176,20 @@ def confidence_of(evidence: float, sigma: float, estimated_book: bool, track_adj why = [] if evidence >= 0.75: score += 1 - why.append(f"notizie forti ({_pct(evidence)})") + why.append(tr(f"notizie forti ({_pct(evidence)})", f"strong news ({_pct(evidence)})")) elif evidence < 0.5: score -= 1 - why.append(f"notizie deboli ({_pct(evidence)})") + why.append(tr(f"notizie deboli ({_pct(evidence)})", f"weak news ({_pct(evidence)})")) if sigma >= 0.05: score -= 1 - why.append(f"stima incerta (±{_pts(sigma)})") + why.append(tr(f"stima incerta (±{_pts(sigma)})", f"uncertain estimate (±{_pts(sigma)})")) if estimated_book: score -= 1 - why.append("prezzi stimati, book non disponibile") + why.append(tr("prezzi stimati, book non disponibile", "estimated prices, book not available")) score += track_adj - why.append("risultati passati buoni" if track_adj > 0 else "risultati passati ancora da dimostrare" if track_adj < 0 else "risultati passati neutri") + why.append(tr("risultati passati buoni", "good past results") if track_adj > 0 + else tr("risultati passati ancora da dimostrare", "past results still to be proven") if track_adj < 0 + else tr("risultati passati neutri", "neutral past results")) level = "alta" if score >= 1 else "bassa" if score <= -2 else "media" return level, why @@ -203,45 +214,56 @@ def build_plan(*, ev: dict, fc: Forecast, signal: str, market_price: float, prof confidence, why = confidence_of(fc.evidence, fc.sigma, ev.get("quote_source") == "estimate", track_adj) if market_closed: - return Plan("NONE", None, "Mercato chiuso", "Non si scambia più: si attende la risoluzione.", + return Plan("NONE", None, tr("Mercato chiuso", "Market closed"), + tr("Non si scambia più: si attende la risoluzione.", "It no longer trades: waiting for resolution."), confidence=confidence, confidence_why=why) # ----- An open position: hold or sell ----- if position: side = position["side"] - s_it = SIDE_IT[side] + s_it = side_label(side) target = sell_above(fc, side, fee_bps, days, hurdle, half_spread) levels["sell_above"] = target p_side_now = p_now if side == "YES" else 1 - p_now pros, cons = _evidence_lines(tally, side) opposite = (signal == "BUY_NO" and side == "YES") or (signal == "BUY_YES" and side == "NO") exit_lines = [ - f"Ordine limite di vendita a {_cents(target)}: a quel prezzo incassare subito rende più che aspettare la risoluzione." - if target else "Nessun prezzo di vendita conviene più che tenere fino alla risoluzione.", - "Niente stop-loss fisso: si vende se una nuova previsione dice che il prezzo è ormai sopra la stima, non perché il prezzo è sceso.", + tr(f"Ordine limite di vendita a {_cents(target)}: a quel prezzo incassare subito rende più che aspettare la risoluzione.", + f"Limit sell order at {_cents(target)}: at that price cashing in now pays more than waiting for resolution.") + if target else tr("Nessun prezzo di vendita conviene più che tenere fino alla risoluzione.", + "No selling price pays more than holding until resolution."), + tr("Niente stop-loss fisso: si vende se una nuova previsione dice che il prezzo è ormai sopra la stima, non perché il prezzo è sceso.", + "No fixed stop-loss: sell if a new forecast says the price is now above the estimate, not because the price fell."), ] entry = position["avg_price"] move = (sell_bid - entry) if sell_bid is not None else None if opposite: - return Plan("SELL", side, f"Vendi le quote {s_it}", - "La nuova previsione si è girata: ora conviene il lato opposto. Vendi al meglio" - + (f" (circa {_cents(sell_bid)})." if sell_bid is not None else "."), + return Plan("SELL", side, tr(f"Vendi le quote {s_it}", f"Sell the {s_it} shares"), + tr("La nuova previsione si è girata: ora conviene il lato opposto. Vendi al meglio", + "The new forecast has turned: the opposite side is now the better bet. Sell at market") + + (tr(f" (circa {_cents(sell_bid)}).", f" (about {_cents(sell_bid)}).") if sell_bid is not None else "."), orders=[{"type": "sell", "side": side, "shares": position["shares"], "limit": sell_bid}], levels=levels, pros=pros, cons=cons + track_cons, exit=exit_lines, confidence=confidence, confidence_why=why, code="forecast_flipped") if sell_bid is not None and target is not None and sell_bid >= target: - vs_entry = f" ({_pts(move)} {'sopra' if move >= 0 else 'sotto'} il prezzo di acquisto)" if move is not None else "" - return Plan("SELL", side, f"Vendi le quote {s_it}", - f"Il prezzo ({_cents(sell_bid)}) ha raggiunto la stima: incassare ora rende più che aspettare " - f"la risoluzione{vs_entry}.", + vs_entry = "" + if move is not None: + vs_entry = (tr(f" ({_pts(move)} sopra il prezzo di acquisto)", f" ({_pts(move)} above the purchase price)") if move >= 0 + else tr(f" ({_pts(move)} sotto il prezzo di acquisto)", f" ({_pts(move)} below the purchase price)")) + return Plan("SELL", side, tr(f"Vendi le quote {s_it}", f"Sell the {s_it} shares"), + tr(f"Il prezzo ({_cents(sell_bid)}) ha raggiunto la stima: incassare ora rende più che aspettare la risoluzione{vs_entry}.", + f"The price ({_cents(sell_bid)}) has reached the estimate: cashing in now pays more than waiting for resolution{vs_entry}."), orders=[{"type": "sell", "side": side, "shares": position["shares"], "limit": target}], levels=levels, pros=pros, cons=cons, exit=exit_lines, confidence=confidence, confidence_why=why, code="target_reached") - hold_pros = pros + [f"La stima ({_pct(p_side_now)}) è ancora sopra il prezzo: tenere rende più che vendere."] - cons = cons + [f"Resta una probabilità del {_pct(1 - p_side_now)} che le quote {s_it} non paghino nulla."] - return Plan("HOLD", side, f"Tieni le quote {s_it}", - f"Tieni fino alla risoluzione, con un ordine di vendita a {_cents(target)}." if target - else "Tieni fino alla risoluzione.", + hold_pros = pros + [tr(f"La stima ({_pct(p_side_now)}) è ancora sopra il prezzo: tenere rende più che vendere.", + f"The estimate ({_pct(p_side_now)}) is still above the price: holding pays more than selling.")] + cons = cons + [tr(f"Resta una probabilità del {_pct(1 - p_side_now)} che le quote {s_it} non paghino nulla.", + f"There is still a {_pct(1 - p_side_now)} chance that the {s_it} shares pay nothing.")] + return Plan("HOLD", side, tr(f"Tieni le quote {s_it}", f"Hold the {s_it} shares"), + tr(f"Tieni fino alla risoluzione, con un ordine di vendita a {_cents(target)}.", + f"Hold until resolution, with a sell order at {_cents(target)}.") if target + else tr("Tieni fino alla risoluzione.", "Hold until resolution."), orders=[{"type": "sell", "side": side, "shares": position["shares"], "limit": target}] if target else [], levels=levels, pros=hold_pros + track_pros, cons=cons + track_cons, exit=exit_lines, confidence=confidence, confidence_why=why) @@ -252,80 +274,98 @@ def build_plan(*, ev: dict, fc: Forecast, signal: str, market_price: float, prof # No signal: say at which prices there would be one side_hint = "YES" if p_now >= market_price else "NO" pros, cons = _evidence_lines(tally, side_hint) - cons = [f"La stima ({_pct(p_now)}) è vicina al prezzo ({_pct(market_price)}): non c'è un vantaggio da sfruttare."] \ - + ([f"Notizie poco informative ({_pct(fc.evidence)}): la stima pesa poco."] if fc.evidence < 0.5 else []) + cons + cons = [tr(f"La stima ({_pct(p_now)}) è vicina al prezzo ({_pct(market_price)}): non c'è un vantaggio da sfruttare.", + f"The estimate ({_pct(p_now)}) is close to the price ({_pct(market_price)}): there is no edge to exploit.")] \ + + ([tr(f"Notizie poco informative ({_pct(fc.evidence)}): la stima pesa poco.", + f"Not very informative news ({_pct(fc.evidence)}): the estimate weighs little.")] if fc.evidence < 0.5 else []) + cons orders = [] if levels["yes_limit"] is not None: orders.append({"type": "buy", "side": "YES", "limit": levels["yes_limit"], "conditional": True}) if levels["no_limit"] is not None: orders.append({"type": "buy", "side": "NO", "limit": levels["no_limit"], "conditional": True}) - return Plan("NONE", None, "Nessuna azione", _conditional_text(levels) or "Nessun prezzo renderebbe conveniente una scommessa.", + return Plan("NONE", None, tr("Nessuna azione", "No action"), + _conditional_text(levels) or tr("Nessun prezzo renderebbe conveniente una scommessa.", "No price would make a bet worth it."), orders=orders, levels=levels, pros=pros, cons=cons + track_cons, confidence=confidence, confidence_why=why) - s_it = SIDE_IT[side] + s_it = side_label(side) p_side = p_now if side == "YES" else 1 - p_now price_side = market_price if side == "YES" else 1 - market_price news_pros, news_cons = _evidence_lines(tally, side) - pros = [f"La stima dà al {s_it} il {_pct(p_side)} contro un prezzo di {_cents(price_side)}: " - f"{_pts(p_side - price_side)} di vantaggio sulla carta."] + pros = [tr(f"La stima dà al {s_it} il {_pct(p_side)} contro un prezzo di {_cents(price_side)}: " + f"{_pts(p_side - price_side)} di vantaggio sulla carta.", + f"The estimate gives {s_it} {_pct(p_side)} against a price of {_cents(price_side)}: " + f"{_pts(p_side - price_side)} of edge on paper.")] if fc.evidence >= 0.75: - pros.append(f"Le notizie informano molto l'esito (forza {_pct(fc.evidence)}).") + pros.append(tr(f"Le notizie informano molto l'esito (forza {_pct(fc.evidence)}).", + f"The news says a lot about the outcome (strength {_pct(fc.evidence)}).")) pros += news_pros if ev.get("net_edge") is not None and ev["net_edge"] >= profile.min_net_edge: - pros.append(f"Anche dopo spread, commissioni e incertezza restano {_pts(ev['net_edge'])} di margine.") + pros.append(tr(f"Anche dopo spread, commissioni e incertezza restano {_pts(ev['net_edge'])} di margine.", + f"Even after spread, fees and uncertainty {_pts(ev['net_edge'])} of margin remain.")) if ev.get("apr") is not None and ev["apr"] >= ev["hurdle_apr"]: - apr = "oltre 1000%" if ev["apr"] >= 10 else _pct(ev["apr"]) - pros.append(f"Rendimento annuo prudente {apr}, sopra la soglia del {_pct(ev['hurdle_apr'])}.") + apr = tr("oltre 1000%", "over 1000%") if ev["apr"] >= 10 else _pct(ev["apr"]) + pros.append(tr(f"Rendimento annuo prudente {apr}, sopra la soglia del {_pct(ev['hurdle_apr'])}.", + f"Prudent annual return {apr}, above the {_pct(ev['hurdle_apr'])} threshold.")) pros += track_pros cons = [r["text"] for r in blocking] + news_cons if ev.get("prob_loss") is not None: - cons.append(f"Resta una probabilità del {_pct(ev['prob_loss'])} di perdere la puntata.") + cons.append(tr(f"Resta una probabilità del {_pct(ev['prob_loss'])} di perdere la puntata.", + f"There is still a {_pct(ev['prob_loss'])} chance of losing the stake.")) if days > 90: - cons.append(f"Il capitale resta bloccato per {days:.0f} giorni.") + cons.append(tr(f"Il capitale resta bloccato per {days:.0f} giorni.", f"The capital stays locked for {days:.0f} days.")) if fc.evidence < 0.5: - cons.append(f"Notizie poco informative (forza {_pct(fc.evidence)}).") + cons.append(tr(f"Notizie poco informative (forza {_pct(fc.evidence)}).", f"Not very informative news (strength {_pct(fc.evidence)}).")) if ev.get("quote_source") == "estimate": - cons.append("Book non disponibile: prezzi e quantità sono stimati.") + cons.append(tr("Book non disponibile: prezzi e quantità sono stimati.", "Book not available: prices and quantities are estimated.")) if excluded_by: - cons.append("Hai escluso questo mercato (o il suo evento o categoria) dal portafoglio simulato.") + cons.append(tr("Hai escluso questo mercato (o il suo evento o categoria) dal portafoglio simulato.", + "You excluded this market (or its event or category) from the simulated portfolio.")) cons += track_cons target = sell_above(fc, side, fee_bps, days, hurdle, half_spread) levels["sell_above"] = target exit_lines = [ - f"Dopo l'acquisto: ordine limite di vendita a {_cents(target)}, il prezzo a cui incassare rende più che aspettare." - if target else "Dopo l'acquisto: tieni fino alla risoluzione.", - "Se una nuova previsione gira la stima, vendi. Niente stop-loss fisso: un prezzo che scende non è da solo un motivo per vendere.", + tr(f"Dopo l'acquisto: ordine limite di vendita a {_cents(target)}, il prezzo a cui incassare rende più che aspettare.", + f"After buying: limit sell order at {_cents(target)}, the price at which cashing in pays more than waiting.") + if target else tr("Dopo l'acquisto: tieni fino alla risoluzione.", "After buying: hold until resolution."), + tr("Se una nuova previsione gira la stima, vendi. Niente stop-loss fisso: un prezzo che scende non è da solo un motivo per vendere.", + "If a new forecast turns the estimate, sell. No fixed stop-loss: a falling price alone is no reason to sell."), ] if ev["verdict"] in ("GO", "SMALL"): orders = [{"type": "buy", "side": side, "limit": ev["limit_price"], "shares": ev["shares"], "usd": ev["outlay"]}] if target: orders.append({"type": "sell", "side": side, "limit": target, "shares": ev["shares"], "after_fill": True}) - small = " (puntata ridotta dai limiti del preset)" if ev["verdict"] == "SMALL" else "" - return Plan("BUY", side, f"Compra {s_it}", - f"Ordine limite: {ev['shares']:.0f} quote {s_it} a non più di {_cents(ev['limit_price'])}, " - f"circa {ev['outlay']:.2f} $".replace(".", ",") + f"{small}.", + small = tr(" (puntata ridotta dai limiti del preset)", " (stake reduced by the preset limits)") if ev["verdict"] == "SMALL" else "" + return Plan("BUY", side, tr(f"Compra {s_it}", f"Buy {s_it}"), + tr(f"Ordine limite: {ev['shares']:.0f} quote {s_it} a non più di {_cents(ev['limit_price'])}, circa {dollars(ev['outlay'])}{small}.", + f"Limit order: {ev['shares']:.0f} {s_it} shares at no more than {_cents(ev['limit_price'])}, about {dollars(ev['outlay'])}{small}."), orders=orders, levels=levels, pros=pros, cons=cons, exit=exit_lines, confidence=confidence, confidence_why=why) if structural or any(r["code"] == "exposure_cap" for r in blocking): - return Plan("AVOID", side, f"Evita, anche se il {s_it} sembra sottovalutato", - "Il vantaggio c'è, ma " + "; ".join(r["text"][0].lower() + r["text"][1:].rstrip(".") for r in (structural or blocking)[:2]) + ".", + return Plan("AVOID", side, tr(f"Evita, anche se il {s_it} sembra sottovalutato", f"Avoid, even though {s_it} looks undervalued"), + tr("Il vantaggio c'è, ma ", "The edge is there, but ") + "; ".join(r["text"][0].lower() + r["text"][1:].rstrip(".") for r in (structural or blocking)[:2]) + ".", levels=levels, pros=pros, cons=cons, confidence=confidence, confidence_why=why) limit = levels["yes_limit"] if side == "YES" else levels["no_limit"] best = ev.get("best_price") if limit is None: - summary = "Al prezzo attuale costi e incertezza si mangiano il vantaggio, e nessun prezzo ragionevole lo recupera." + summary = tr("Al prezzo attuale costi e incertezza si mangiano il vantaggio, e nessun prezzo ragionevole lo recupera.", + "At the current price costs and uncertainty eat up the edge, and no reasonable price recovers it.") elif best is not None and limit >= best: # At the best ask it would be worth it, but not for a useful amount (book, minimum order) - summary = (f"Il prezzo migliore ({_cents(best)}) è al limite: conviene solo per poche quote. " - f"Metti un ordine limite a {_cents(limit)} e lascia che il prezzo venga da te.") + summary = tr(f"Il prezzo migliore ({_cents(best)}) è al limite: conviene solo per poche quote. " + f"Metti un ordine limite a {_cents(limit)} e lascia che il prezzo venga da te.", + f"The best price ({_cents(best)}) is at the limit: worth it only for a few shares. " + f"Place a limit order at {_cents(limit)} and let the price come to you.") else: - summary = (f"Al prezzo attuale ({_cents(best)}) costi e incertezza si mangiano il vantaggio. " - f"Metti un ordine limite a {_cents(limit)}: se il prezzo scende fin lì, conviene.") - return Plan("WAIT", side, f"Aspetta: compra {s_it} a {_cents(limit)} o meno" if limit else "Aspetta", summary, + summary = tr(f"Al prezzo attuale ({_cents(best)}) costi e incertezza si mangiano il vantaggio. " + f"Metti un ordine limite a {_cents(limit)}: se il prezzo scende fin lì, conviene.", + f"At the current price ({_cents(best)}) costs and uncertainty eat up the edge. " + f"Place a limit order at {_cents(limit)}: if the price falls that far, it is worth it.") + return Plan("WAIT", side, tr(f"Aspetta: compra {s_it} a {_cents(limit)} o meno", f"Wait: buy {s_it} at {_cents(limit)} or less") + if limit else tr("Aspetta", "Wait"), summary, orders=[{"type": "buy", "side": side, "limit": limit, "conditional": True}] if limit else [], levels=levels, pros=pros, cons=cons, exit=exit_lines, confidence=confidence, confidence_why=why) @@ -351,12 +391,16 @@ def _cap_with_evaluation(levels: dict, ev: dict, signal: str, half_spread: float def _conditional_text(levels: dict) -> Optional[str]: parts = [] if levels.get("buy_yes_below") is not None: - parts.append(f"conviene comprare SÌ se il prezzo scende sotto {_cents(levels['buy_yes_below'])} " - f"(ordine SÌ a {_cents(levels['yes_limit'])})") + parts.append(tr(f"conviene comprare SÌ se il prezzo scende sotto {_cents(levels['buy_yes_below'])} " + f"(ordine SÌ a {_cents(levels['yes_limit'])})", + f"buying YES pays if the price falls below {_cents(levels['buy_yes_below'])} " + f"(YES order at {_cents(levels['yes_limit'])})")) if levels.get("buy_no_above") is not None: - parts.append(f"conviene comprare NO se il prezzo del SÌ sale sopra {_cents(levels['buy_no_above'])} " - f"(ordine NO a {_cents(levels['no_limit'])})") + parts.append(tr(f"conviene comprare NO se il prezzo del SÌ sale sopra {_cents(levels['buy_no_above'])} " + f"(ordine NO a {_cents(levels['no_limit'])})", + f"buying NO pays if the YES price rises above {_cents(levels['buy_no_above'])} " + f"(NO order at {_cents(levels['no_limit'])})")) if not parts: return None - text = " e ".join(parts) + text = tr(" e ", " and ").join(parts) return text[0].upper() + text[1:] + "." diff --git a/backend/config.py b/backend/config.py index 8ac6c6c..fe8daf0 100644 --- a/backend/config.py +++ b/backend/config.py @@ -3,6 +3,10 @@ from pydantic_settings import BaseSettings, SettingsConfigDict class Settings(BaseSettings): + # Language of the texts written without a dashboard request: Telegram alerts, news summaries, + # stored reasons. The dashboard picks its own (IT/EN button) and sends it with each request. + APP_LANGUAGE: str = "en" # "en" or "it" + # Database DATABASE_URL: str = "postgresql+asyncpg://postgres:password@localhost:5432/postgres" SIMILARITY_THRESHOLD: float = 0.92 # near-identical titles: same news diff --git a/backend/i18n.py b/backend/i18n.py new file mode 100644 index 0000000..c822a39 --- /dev/null +++ b/backend/i18n.py @@ -0,0 +1,50 @@ +"""Language of the texts the API writes for people: plans, reasons, error messages, alerts. + +A dashboard request carries X-Lang (it / en) and a middleware sets it for the whole request; +background work (alerts, Telegram, summaries) uses APP_LANGUAGE. Texts are written as +tr(italian, english) next to the code that builds them. +""" +from contextvars import ContextVar +from typing import Optional + +from backend.config import settings + +LANGS = ("it", "en") +_lang: ContextVar[Optional[str]] = ContextVar("lang", default=None) + + +def lang() -> str: + current = _lang.get() + if current in LANGS: + return current + return settings.APP_LANGUAGE if settings.APP_LANGUAGE in LANGS else "en" + + +def set_lang(value: Optional[str]): + """Sets the language for the current context; returns the token for reset_lang.""" + value = (value or "").strip().lower()[:2] + return _lang.set(value if value in LANGS else None) + + +def reset_lang(token) -> None: + _lang.reset(token) + + +def tr(it: str, en: str) -> str: + return it if lang() == "it" else en + + +def dec(x: float, digits: int = 1) -> str: + """A number with the decimal separator of the language: 1,5 / 1.5.""" + text = f"{x:.{digits}f}" + return text.replace(".", ",") if lang() == "it" else text + + +def dollars(x: float, digits: int = 2) -> str: + """41,02 $ / $41.02""" + return f"{dec(x, digits)} $" if lang() == "it" else f"${dec(x, digits)}" + + +def side(s: str) -> str: + """YES / NO as shown to people.""" + return tr("SÌ", "YES") if s == "YES" else "NO" diff --git a/backend/ingestor/fetcher.py b/backend/ingestor/fetcher.py index 93808d6..98cd0df 100644 --- a/backend/ingestor/fetcher.py +++ b/backend/ingestor/fetcher.py @@ -14,6 +14,7 @@ import httpx from backend.config import settings +from backend.i18n import tr logger = logging.getLogger(__name__) _TAG_RE = re.compile(r"<[^>]+>") @@ -53,17 +54,17 @@ async def check_url(url: str) -> None: """Rejects non-HTTP URLs and, unless allowed, hosts resolving to internal addresses (SSRF).""" parsed = urlparse(url) if parsed.scheme not in ("http", "https") or not parsed.hostname: - raise FeedError("L'indirizzo deve iniziare con http:// o https://") + raise FeedError(tr("L'indirizzo deve iniziare con http:// o https://", "The address must start with http:// or https://")) if settings.ALLOW_PRIVATE_FEEDS: return try: addresses = await resolve_host(parsed.hostname) except OSError: - raise FeedError(f"Il dominio {parsed.hostname} non esiste o non risponde") + raise FeedError(tr(f"Il dominio {parsed.hostname} non esiste o non risponde", f"The domain {parsed.hostname} does not exist or does not respond")) for address in addresses: ip = ipaddress.ip_address(address.split("%")[0]) if not ip.is_global or ip.is_multicast: - raise FeedError("L'indirizzo punta a una rete interna: non è consentito") + raise FeedError(tr("L'indirizzo punta a una rete interna: non è consentito", "The address points to an internal network: not allowed")) async def download(url: str) -> bytes: @@ -80,26 +81,26 @@ async def download(url: str) -> bytes: url = urljoin(url, res.headers["location"]) continue if res.status_code >= 400: - raise FeedError(f"Il server ha risposto con errore {res.status_code}") + raise FeedError(tr(f"Il server ha risposto con errore {res.status_code}", f"The server answered with error {res.status_code}")) chunks, size = [], 0 async for chunk in res.aiter_bytes(): size += len(chunk) if size > settings.FEED_MAX_BYTES: - raise FeedError("Il feed è troppo grande") + raise FeedError(tr("Il feed è troppo grande", "The feed is too large")) chunks.append(chunk) return b"".join(chunks) except httpx.TimeoutException: - raise FeedError("Il server non ha risposto in tempo") + raise FeedError(tr("Il server non ha risposto in tempo", "The server did not answer in time")) except httpx.HTTPError as e: - raise FeedError(f"Connessione non riuscita: {e.__class__.__name__}") - raise FeedError("Troppi reindirizzamenti") + raise FeedError(tr(f"Connessione non riuscita: {e.__class__.__name__}", f"Connection failed: {e.__class__.__name__}")) + raise FeedError(tr("Troppi reindirizzamenti", "Too many redirects")) def parse_feed(content: bytes) -> FeedResult: feed = feedparser.parse(content) # feedparser does not always flag an HTML page as an error: no entries and no feed version means "not a feed" if not feed.entries and (feed.get("bozo") or not feed.get("version")): - raise FeedError("Il contenuto non è un feed RSS o Atom valido") + raise FeedError(tr("Il contenuto non è un feed RSS o Atom valido", "The content is not a valid RSS or Atom feed")) entries = [] for entry in feed.entries: title = clean_html(entry.get("title", "")) diff --git a/backend/ingestor/sources.py b/backend/ingestor/sources.py index 33d829f..f645d35 100644 --- a/backend/ingestor/sources.py +++ b/backend/ingestor/sources.py @@ -10,6 +10,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from backend.db.models import Source +from backend.i18n import tr logger = logging.getLogger(__name__) @@ -35,6 +36,7 @@ def load_catalog() -> tuple[dict, ...]: "url": str(feed["url"]).strip(), "category_hint": category if category in CATEGORIES else None, "description": feed.get("description"), + "description_it": feed.get("description_it"), "active": bool(feed.get("active", True)), }) return tuple(catalog) @@ -44,16 +46,16 @@ def normalize_feed_url(url: str) -> str: url = (url or "").strip() parsed = urlparse(url) if parsed.scheme not in ("http", "https") or not parsed.netloc: - raise ValueError("Inserisci un indirizzo completo, che inizi con http:// o https://") + raise ValueError(tr("Inserisci un indirizzo completo, che inizi con http:// o https://", "Enter a full address, starting with http:// or https://")) if len(url) > 2000: - raise ValueError("L'indirizzo è troppo lungo") + raise ValueError(tr("L'indirizzo è troppo lungo", "The address is too long")) return url def validate_name(name: str) -> str: name = (name or "").strip() if not 2 <= len(name) <= 80: - raise ValueError("Il nome deve avere tra 2 e 80 caratteri") + raise ValueError(tr("Il nome deve avere tra 2 e 80 caratteri", "The name must have between 2 and 80 characters")) return name @@ -61,7 +63,7 @@ def validate_category(category: Optional[str]) -> Optional[str]: if category in (None, ""): return None if category not in CATEGORIES: - raise ValueError("Categoria non valida") + raise ValueError(tr("Categoria non valida", "Invalid category")) return category diff --git a/backend/main.py b/backend/main.py index 3ed21c1..3307948 100644 --- a/backend/main.py +++ b/backend/main.py @@ -6,6 +6,7 @@ from fastapi.staticfiles import StaticFiles from backend.config import settings from backend import overrides +from backend.i18n import reset_lang, set_lang from backend.ingestor import reembed from backend.backtest import engine as backtest_engine from backend.db.database import init_db, SessionLocal @@ -45,7 +46,7 @@ async def lifespan(app: FastAPI): if cors_origins: app.add_middleware( CORSMiddleware, allow_origins=cors_origins, allow_credentials=True, - allow_methods=["GET", "POST"], allow_headers=["Content-Type", "X-CSRF-Token"], + allow_methods=["GET", "POST"], allow_headers=["Content-Type", "X-CSRF-Token", "X-Lang"], ) CSP = ( @@ -55,6 +56,15 @@ async def lifespan(app: FastAPI): "base-uri 'self'; form-action 'self'; frame-ancestors 'none'" ) +@app.middleware("http") +async def request_language(request: Request, call_next): + # The dashboard sends its language (IT/EN button): plans, reasons and errors follow it + token = set_lang(request.headers.get("X-Lang")) + try: + return await call_next(request) + finally: + reset_lang(token) + @app.middleware("http") async def security_headers(request: Request, call_next): response = await call_next(request) diff --git a/backend/markets/bulk.py b/backend/markets/bulk.py index 827828b..eebbb5b 100644 --- a/backend/markets/bulk.py +++ b/backend/markets/bulk.py @@ -18,6 +18,7 @@ from backend.db.models import Article, Market, MarketArticleLink from backend.markets import service from backend.markets.targeted import run_targeted_search +from backend.i18n import tr logger = logging.getLogger(__name__) @@ -102,7 +103,7 @@ def stop() -> bool: if not job.running: return False _cancel.set() - job.message = "Interruzione in corso…" + job.message = tr("Interruzione in corso…", "Stopping…") return True @@ -114,16 +115,16 @@ async def wait() -> None: async def _refresh_markets(session: AsyncSession) -> None: """Fresh prices and links, so every edge is computed against the current price.""" - job.message = "Aggiornamento prezzi e collegamenti…" + job.message = tr("Aggiornamento prezzi e collegamenti…", "Updating prices and links…") async with service._market_lock: # waits if the scheduled pipeline is syncing try: await service.sync_markets(session) except Exception as e: await session.rollback() logger.warning(f"Bulk prediction: market sync failed, using last prices: {e}") - job.message = "Ricerca di notizie mirate…" + job.message = tr("Ricerca di notizie mirate…", "Targeted news search…") await run_targeted_search(session) - job.message = "Collegamento delle notizie ai mercati…" + job.message = tr("Collegamento delle notizie ai mercati…", "Linking news to markets…") await service.refresh_links(session) @@ -154,7 +155,7 @@ async def _run_tagged(refresh_first: bool) -> None: break except Exception as e: # pragma: no cover - unexpected failure (DB down...) logger.exception("Bulk prediction failed") - job.message = f"Errore: {e}" + job.message = tr(f"Errore: {e}", f"Error: {e}") finally: job.running = False job.current = None @@ -178,15 +179,15 @@ async def _predict_one(session: AsyncSession, market: Market) -> bool: return True except BudgetExceeded as e: await session.rollback() - job.message = f"Fermata: {e}" + job.message = tr(f"Fermata: {e}", f"Stopped: {e}") return False except RateLimited as e: await session.rollback() waits += 1 if waits > MAX_RATE_LIMIT_WAITS: - job.message = "Fermata: Jev continua a rifiutare le richieste per limite di frequenza. Riprova più tardi." + job.message = tr("Fermata: Jev continua a rifiutare le richieste per limite di frequenza. Riprova più tardi.", "Stopped: Jev keeps refusing requests because of the rate limit. Try again later.") return False - job.message = f"Limite di richieste Jev: riprendo tra {max(1, round(e.retry_in))} s" + job.message = tr(f"Limite di richieste Jev: riprendo tra {max(1, round(e.retry_in))} s", f"Jev request limit: resuming in {max(1, round(e.retry_in))} s") try: await asyncio.wait_for(_cancel.wait(), timeout=max(1.0, e.retry_in)) job.stopped, job.message = True, None @@ -210,6 +211,7 @@ async def _predict_one(session: AsyncSession, market: Market) -> bool: job.errors.append(f"{question[:80]}: {e}") logger.error(f"Bulk prediction failed for {question!r}: {e}") if job.consecutive_failures >= MAX_CONSECUTIVE_FAILURES: - job.message = f"Fermata dopo {MAX_CONSECUTIVE_FAILURES} errori consecutivi: controlla la connessione e TYPESAFE_API_KEY." + job.message = tr(f"Fermata dopo {MAX_CONSECUTIVE_FAILURES} errori consecutivi: controlla la connessione e TYPESAFE_API_KEY.", + f"Stopped after {MAX_CONSECUTIVE_FAILURES} consecutive errors: check the connection and TYPESAFE_API_KEY.") return False return True diff --git a/backend/markets/service.py b/backend/markets/service.py index 681ccbb..abdf15e 100644 --- a/backend/markets/service.py +++ b/backend/markets/service.py @@ -17,6 +17,7 @@ from backend.markets.targeted import run_targeted_search from backend.alerts.service import run_alerts from backend.markets.matching import EvidenceItem, extract_terms, match_score, outlet_priors, rank_evidence, term_overlap +from backend.i18n import tr logger = logging.getLogger(__name__) @@ -308,13 +309,13 @@ async def ask_jev(state, questions, max_wait: Optional[float] = None, samples: O async def predict_market(session: AsyncSession, market: Market, max_wait: Optional[float] = None) -> MarketPrediction: """Runs a Jev forecast for one market and stores it together with the betting signal.""" if not jev.is_enabled(): - raise jev.JevUnavailableError("TYPESAFE_API_KEY is not configured") + raise jev.JevUnavailableError(tr("TYPESAFE_API_KEY non è configurata", "TYPESAFE_API_KEY is not configured")) if market.yes_price is None: - raise ValueError("Market has no current price") + raise ValueError(tr("Il mercato non ha un prezzo attuale", "Market has no current price")) evidence = await get_market_evidence(session, market.id) if not evidence: - raise LookupError("No related news found for this market") + raise LookupError(tr("Nessuna notizia collegata a questo mercato", "No related news found for this market")) state, questions = build_jev_request(market, evidence) response, model_p, evidence_strength, samples = await ask_jev(state, questions, max_wait=max_wait) diff --git a/backend/multi/service.py b/backend/multi/service.py index 849388c..adb26b8 100644 --- a/backend/multi/service.py +++ b/backend/multi/service.py @@ -24,6 +24,7 @@ from backend.markets.forecast import model_weight, pool_distribution from backend.markets.matching import extract_terms, match_score, outlet_priors, rank_evidence, term_overlap from backend.markets.service import EVIDENCE_CRITERIA, _candidate_distance +from backend.i18n import tr logger = logging.getLogger(__name__) @@ -203,7 +204,7 @@ def market_distribution(outcomes: list[MultiOutcome], max_outcomes: int) -> list other = sum(o.yes_price for o in rest) if rest: # Not Polymarket's own "Other" outcome (if any): the sum of the outcomes not listed to Jev - items.append({"id": OTHER_ID, "label": f"{OTHER_LABEL} ({len(rest)})", "price": other}) + items.append({"id": OTHER_ID, "label": f"{tr(OTHER_LABEL, 'Other outcomes')} ({len(rest)})", "price": other}) total = sum(i["price"] for i in items) or 1.0 for i in items: i["market"] = round(i["price"] / total, 4) @@ -296,13 +297,13 @@ async def ask_jev_distribution(state, questions, max_wait: Optional[float] = Non async def predict_event(session: AsyncSession, event: MultiEvent, max_wait: Optional[float] = None) -> MultiPrediction: if not jev.is_enabled(): - raise jev.JevUnavailableError("TYPESAFE_API_KEY is not configured") + raise jev.JevUnavailableError(tr("TYPESAFE_API_KEY non è configurata", "TYPESAFE_API_KEY is not configured")) items = market_distribution(await outcomes_of(session, event.id), settings.MULTI_MAX_OUTCOMES) if len(items) < 2: - raise ValueError("L'evento non ha abbastanza esiti con un prezzo") + raise ValueError(tr("L'evento non ha abbastanza esiti con un prezzo", "The event does not have enough outcomes with a price")) evidence = await get_evidence(session, event.id) if not evidence: - raise LookupError("Nessuna notizia recente collegata a questo evento") + raise LookupError(tr("Nessuna notizia recente collegata a questo evento", "No recent news linked to this event")) state, questions, keys = build_request(event, items, evidence) response, probs, strength = await ask_jev_distribution(state, questions, max_wait=max_wait) diff --git a/backend/overrides.py b/backend/overrides.py index 64b3ca8..1de105d 100644 --- a/backend/overrides.py +++ b/backend/overrides.py @@ -10,6 +10,7 @@ from backend.config import settings from backend.db.models import AppSetting +from backend.i18n import tr ALLOWED = { "MODEL_WEIGHT_MAX": (0.0, 1.0), @@ -46,11 +47,11 @@ async def load(db: AsyncSession) -> None: async def set_values(db: AsyncSession, values: dict, note: str | None = None) -> dict: for key, value in values.items(): if key not in ALLOWED: - raise ValueError(f"{key} non si può modificare dalla dashboard") + raise ValueError(tr(f"{key} non si può modificare dalla dashboard", f"{key} cannot be changed from the dashboard")) lo, hi = ALLOWED[key] value = float(value) if not lo <= value <= hi: - raise ValueError(f"{key} deve essere tra {lo} e {hi}") + raise ValueError(tr(f"{key} deve essere tra {lo} e {hi}", f"{key} must be between {lo} and {hi}")) row = await db.get(AppSetting, key) if row is None: row = AppSetting(key=key, value=value) diff --git a/docker-compose.yml b/docker-compose.yml index 354ae2e..49e94f9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,5 @@ -# Everything reads its settings from .env (copy .env.example). On a server, see the README -# section "Deploy su un VPS con Cloudflare Tunnel". +# Everything reads its settings from .env (copy .env.example). On a server, see +# docs/deploy.md, "Deploy on a VPS with Cloudflare Tunnel". services: db: image: pgvector/pgvector:pg16 @@ -18,7 +18,7 @@ services: retries: 10 api: - # CPU-only image; for a local NVIDIA GPU add docker-compose.gpu.yml (see README) + # CPU-only image; for a local NVIDIA GPU add docker-compose.gpu.yml (see docs/deploy.md) build: context: . dockerfile: Dockerfile diff --git a/docs/README.md b/docs/README.md index a295cc4..891bc15 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,17 +1,17 @@ -# Documentazione +# Documentation -[← Torna al README](../README.md) +[← Back to the README](../README.md) · [Italiano](it/README.md) -| | Pagina | Contenuto | +| | Page | Contents | |---|---|---| -| 🧭 | [Guida alla dashboard](guida.md) | Le pagine, il flusso di lavoro consigliato, cosa fare se la dashboard non si apre | -| 🎯 | [Il metodo](metodo.md) | Raccolta, collegamento notizie–mercati, previsione, mercati a più esiti | -| 💶 | [Strategia e portafoglio](strategia.md) | Valutazione economica, cosa fare e quando vendere, portafoglio simulato | -| 🔔 | [Allerte](allerte.md) | Notifiche Telegram e misura del loro anticipo sul prezzo | -| 📊 | [Backtest e calibrazione](verifica.md) | Quanto fidarsi delle previsioni, sul passato e sul presente | -| 💳 | [Uso e costi](costi.md) | Chiamate a pagamento, stima dei costi, limiti giornalieri | -| ⚙️ | [Configurazione](configurazione.md) | Tutte le variabili di `.env` | -| 🔒 | [Accesso e sicurezza](sicurezza.md) | Utenti e ruoli, sessioni, protezioni | -| 🚀 | [Deploy](deploy.md) | Immagini CPU/GPU, uso in locale e nella rete di casa, VPS con Cloudflare Tunnel, backup | -| 🔌 | [API](api.md) | Gli endpoint REST | -| 🛠️ | [Sviluppo e test](sviluppo.md) | Test, CI, struttura del codice | +| 🧭 | [Dashboard guide](guide.md) | The pages, the recommended workflow, what to do if the dashboard does not open | +| 🎯 | [The method](method.md) | Collection, news–market linking, forecasting, multi-outcome markets | +| 💶 | [Strategy and portfolio](strategy.md) | Economic assessment, what to do and when to sell, simulated portfolio | +| 🔔 | [Alerts](alerts.md) | Telegram notifications and how much they get ahead of the price | +| 📊 | [Backtest and calibration](verification.md) | How much to trust the forecasts, on the past and on the present | +| 💳 | [Usage and costs](costs.md) | Paid calls, cost estimate, daily limits | +| ⚙️ | [Configuration](configuration.md) | Every `.env` variable | +| 🔒 | [Access and security](security.md) | Users and roles, sessions, protections | +| 🚀 | [Deploy](deploy.md) | CPU/GPU images, local and home-network use, VPS with Cloudflare Tunnel, backups | +| 🔌 | [API](api.md) | The REST endpoints | +| 🛠️ | [Development and tests](development.md) | Tests, CI, code structure | diff --git a/docs/alerts.md b/docs/alerts.md new file mode 100644 index 0000000..e5cd73b --- /dev/null +++ b/docs/alerts.md @@ -0,0 +1,48 @@ +# News–price alerts + +[← Back to the README](../README.md) · [All documentation](README.md) · [Italiano](it/allerte.md) + +Telegram notifications when fresh news moves the estimate before the price. + +On Polymarket the edge comes above all from speed: news comes out and the price takes minutes +or hours to adjust. Alerts are there to get in first. + +**When an alert fires.** Every time news is linked to markets: +- in the full collection; +- in a quick check every `ALERT_SCAN_MINUTES` minutes, which fetches the sources and links the + news without summaries or classification. + +For each new link the app checks that the news: +- is fresh (configurable maximum age, 6 hours by default); +- is relevant (60 % match by default); +- comes from a reliable source and is not an opinion piece; +- concerns a followed category. + +For each market the best news item counts. Markets on pause, that is with an alert in the +last 3 hours, are skipped. + +**What happens.** +1. The price is refreshed from Polymarket at that moment. +2. Jev assesses the market: it is one call, counted in the daily limit (30 by default). +3. The economic assessment decides whether it is worth it and how much to stake. The + simulated bet follows the portfolio rules. +4. If it is worth it a Telegram message arrives with the news, the price, the estimate, the + edge, the stake and the maximum price. During quiet hours the message arrives silently. + Messages are written in `APP_LANGUAGE` (English by default, `it` for Italian). +5. Every `ALERT_FOLLOWUP_MINUTES` minutes the price 15 minutes, 1, 6 and 24 hours after the + alert is recorded. + +**The results.** The *favourable move* measures how many points the price moved in the +recommended direction. If it is positive, the alert came before the market. The *Alerts* page +shows the average and the share of favourable alerts for each interval, and the outcome of the +markets already resolved. Assessments that were not worth it are saved too («All +assessments»), so you can see how much alerts cost compared with what they return. + +**Setting up Telegram.** +1. Create a bot with [@BotFather](https://t.me/BotFather) and copy the token. +2. Send a message to the bot, then open `https://api.telegram.org/bot/getUpdates`: + the number in `chat.id` is your `TELEGRAM_CHAT_ID`. For a group, add the bot to the group; + the id starts with `-`. +3. Put the two values in `.env`, restart and press «Send test message» on the *Alerts* page. + +The token stays only in `.env`: it does not appear in the API, the logs or error messages. diff --git a/docs/api.md b/docs/api.md index 9f09c01..acda2a4 100644 --- a/docs/api.md +++ b/docs/api.md @@ -1,133 +1,135 @@ # API -[← Torna al README](../README.md) · [Tutta la documentazione](README.md) +[← Back to the README](../README.md) · [All documentation](README.md) · [Italiano](it/api.md) -Gli endpoint REST dell'app. +The app's REST endpoints. +Full interactive documentation on `/docs` (if `API_DOCS_ENABLED=true`). Every endpoint requires +a session; `POST` ones also the `X-CSRF-Token` header, and those that start jobs or paid calls +(`/ingest`, `/markets/sync`, `/markets/{id}/predict`, `/markets/predict-all`) the `admin` role. -Documentazione interattiva completa su `/docs` (se `API_DOCS_ENABLED=true`). Tutti gli -endpoint richiedono una sessione; quelli `POST` anche l'header `X-CSRF-Token`, e quelli che -avviano lavori o chiamate a pagamento (`/ingest`, `/markets/sync`, `/markets/{id}/predict`, -`/markets/predict-all`) il ruolo `admin`. +**Language.** Send `X-Lang: en` or `X-Lang: it` to get the texts the server writes (plans, +reasons, error messages, preset labels) in that language; without it the server uses +`APP_LANGUAGE`. The dashboard sends it on every request. -### Accesso +### Access -| Metodo | Path | Descrizione | +| Method | Path | Description | |---|---|---| -| POST | `/auth/login` | `{username, password}` → cookie di sessione, utente e token CSRF | -| POST | `/auth/logout` | Chiude la sessione corrente | -| GET | `/auth/me` | Utente corrente e token CSRF | -| POST | `/auth/password` | `{current_password, new_password}`; chiude le altre sessioni | +| POST | `/auth/login` | `{username, password}` → session cookie, user and CSRF token | +| POST | `/auth/logout` | Closes the current session | +| GET | `/auth/me` | Current user and CSRF token | +| POST | `/auth/password` | `{current_password, new_password}`; closes the other sessions | -### Notizie +### News -| Metodo | Path | Descrizione | +| Method | Path | Description | |---|---|---| -| GET | `/articles` | Notizie con ricerca, filtri e ordinamento | -| GET | `/articles/{id}` | Dettaglio di una notizia | -| GET | `/categories` | Numero di notizie per categoria | -| POST | `/ingest` | Avvia subito la pipeline completa (admin) | -| POST | `/ingest/reclassify` | Riclassifica le notizie salvate (admin, `limit` fino a 1000) | +| GET | `/articles` | News with search, filters and sorting | +| GET | `/articles/{id}` | Detail of a news item | +| GET | `/categories` | Number of news items per category | +| POST | `/ingest` | Starts the full pipeline right away (admin) | +| POST | `/ingest/reclassify` | Reclassifies the saved news (admin, `limit` up to 1000) | -`/articles` accetta: -- **ricerca:** `q` (testo), `scope` (`all` o `title`), `sort` (`relevance`, `score`, `recent`); -- **filtri:** `category`, `region`, `source_id`, `since_hours`, `min_market_relevance`, `hide_opinion`; -- **paginazione:** `limit`, `offset`; -- **pesi del punteggio:** `w_authority`, `w_tech`, `w_urgency`, `w_clickbait`, con le soglie `max_clickbait`, `min_authority`. +`/articles` accepts: +- **search:** `q` (text), `scope` (`all` or `title`), `sort` (`relevance`, `score`, `recent`); +- **filters:** `category`, `region`, `source_id`, `since_hours`, `min_market_relevance`, `hide_opinion`; +- **pagination:** `limit`, `offset`; +- **score weights:** `w_authority`, `w_tech`, `w_urgency`, `w_clickbait`, with the thresholds `max_clickbait`, `min_authority`. -Con `q` ogni notizia ha anche `title_highlight` e `snippet`, con le parole trovate racchiuse -tra i caratteri `\u0002` e `\u0003`. +With `q` each news item also has `title_highlight` and `snippet`, with the words found wrapped +in the characters `\u0002` and `\u0003`. ```bash curl -b cookie.txt "localhost:8000/articles?q=fed%20rate%20cut&since_hours=72&min_market_relevance=0.5" ``` -### Fonti +### Sources -| Metodo | Path | Descrizione | +| Method | Path | Description | |---|---|---| -| GET | `/sources` | Fonti con stato dell'ultimo aggiornamento e numero di notizie | -| POST | `/sources` | Aggiunge una fonte `{name, url, category_hint, active}` (admin) | -| PATCH | `/sources/{id}` | Modifica nome, indirizzo, argomento o attivazione (admin) | -| DELETE | `/sources/{id}` | Elimina la fonte; se ha notizie serve `delete_articles=true` (admin) | -| POST | `/sources/{id}/fetch` | Scarica subito le notizie di questa fonte (admin) | -| POST | `/sources/test` | Prova un feed senza salvarlo: titolo, numero di notizie, esempi (admin) | -| GET | `/sources/catalog` | Fonti consigliate da `feeds.yaml`, con quelle già aggiunte | -| POST | `/sources/catalog` | Aggiunge fonti dal catalogo `{urls: [...]}` (admin) | - -### Mercati e previsioni - -| Metodo | Path | Descrizione | +| GET | `/sources` | Sources with the status of the latest update and number of news items | +| POST | `/sources` | Adds a source `{name, url, category_hint, active}` (admin) | +| PATCH | `/sources/{id}` | Changes name, address, topic or activation (admin) | +| DELETE | `/sources/{id}` | Deletes the source; if it has news `delete_articles=true` is needed (admin) | +| POST | `/sources/{id}/fetch` | Fetches this source's news right away (admin) | +| POST | `/sources/test` | Tests a feed without saving it: title, number of news items, samples (admin) | +| GET | `/sources/catalog` | Recommended sources from `feeds.yaml`, marking those already added (description in the request language) | +| POST | `/sources/catalog` | Adds sources from the catalogue `{urls: [...]}` (admin) | + +### Markets and forecasts + +| Method | Path | Description | |---|---|---| -| POST | `/markets/sync` | Sync mercati + collegamento notizie (+ previsioni se `PREDICTION_AUTO`) | -| GET | `/markets` | Mercati con ultima previsione. Filtri: `q`, `only_linked`, `include_closed`. Ordinamento: `sort` = `volume`, `end_date`, `price`, `signal` (ultima previsione), `edge`, `news`, `liquidity`, `question`; `order` = `asc`/`desc` (default sensato per ogni campo, valori mancanti sempre in fondo) | -| GET | `/markets/{id}` | Notizie collegate (rilevanza e impatto) e storico previsioni | -| POST | `/markets/{id}/predict` | Previsione Jev immediata (aggiorna prima il prezzo) | -| POST | `/markets/{id}/search-news` | Ricerca mirata su Google News per questo mercato; restituisce `{query, added, linked}` (admin) | -| POST | `/markets/predict-all` | Avvia in background la previsione Jev su tutti i mercati aperti con notizie recenti. `only_new=true`: solo mai valutati o con notizie nuove; `refresh_first=false`: salta l'aggiornamento dei prezzi. `409` se è già in corso (admin) | -| GET | `/markets/predict-all` | Avanzamento (`total`, `done`, `skipped`, `failed`, `current`, `message`) e mercati valutabili `eligible: {all, new}` (admin) | -| POST | `/markets/predict-all/stop` | Interrompe dopo il mercato in corso (admin) | -| GET | `/predictions/opportunities` | Mercati con edge maggiore. Filtri: `min_edge`, `min_evidence`, `include_hold` | -| GET | `/predictions/calibration` | Brier score sui mercati risolti | -| GET | `/status` | Configurazione (Jev attivo, soglie) e contatori per la dashboard | -| GET | `/markets/{id}/economics` | Valutazione economica dal vivo dell'ultima previsione e piano (`strategy`: azione, ordini, prezzi, motivi; `preset` opzionale) | -| POST | `/markets/{id}/paper-bet` | Aggiunge subito la scommessa simulata, se conviene (admin) | - -### Portafoglio simulato - -| Metodo | Path | Descrizione | +| POST | `/markets/sync` | Market sync + news linking (+ forecasts if `PREDICTION_AUTO`) | +| GET | `/markets` | Markets with their latest forecast. Filters: `q`, `only_linked`, `include_closed`. Sorting: `sort` = `volume`, `end_date`, `price`, `signal` (latest forecast), `edge`, `news`, `liquidity`, `question`; `order` = `asc`/`desc` (a sensible default for each field, missing values always last) | +| GET | `/markets/{id}` | Linked news (relevance and impact) and forecast history | +| POST | `/markets/{id}/predict` | Immediate Jev forecast (refreshes the price first) | +| POST | `/markets/{id}/search-news` | Targeted Google News search for this market; returns `{query, added, linked}` (admin) | +| POST | `/markets/predict-all` | Starts in the background the Jev forecast on every open market with recent news. `only_new=true`: only never assessed or with new news; `refresh_first=false`: skips the price update. `409` if already running (admin) | +| GET | `/markets/predict-all` | Progress (`total`, `done`, `skipped`, `failed`, `current`, `message`) and assessable markets `eligible: {all, new}` (admin) | +| POST | `/markets/predict-all/stop` | Stops after the current market (admin) | +| GET | `/predictions/opportunities` | Markets with the highest edge. Filters: `min_edge`, `min_evidence`, `include_hold` | +| GET | `/predictions/calibration` | Brier score on resolved markets | +| GET | `/status` | Configuration (Jev on, thresholds) and counters for the dashboard | +| GET | `/markets/{id}/economics` | Live economic assessment of the latest forecast and plan (`strategy`: action, orders, prices, reasons; optional `preset`) | +| POST | `/markets/{id}/paper-bet` | Adds the simulated bet right away, if it is worth it (admin) | + +### Simulated portfolio + +| Method | Path | Description | |---|---|---| -| GET | `/portfolio` | Riepilogo, curva del capitale, preset disponibili | +| GET | `/portfolio` | Summary, capital curve, available presets | | PUT | `/portfolio/settings` | `{preset, auto_paper, auto_sell}` (admin) | -| POST | `/portfolio/reset` | `{bankroll, preset}`: cancella le scommesse simulate e ricomincia (admin) | -| GET | `/portfolio/bets` | Scommesse: `status` = `open`, `settled`, `excluded`, `all`; quelle aperte hanno il piano d'uscita (`plan`) | -| POST | `/portfolio/bets/{id}/exclude` · `/include` | Esclude o riammette una scommessa (admin) | -| POST | `/portfolio/bets/{id}/sell` | Vende subito una scommessa aperta sul book (admin) | -| GET · POST | `/portfolio/exclusions` | Esclusioni `{kind: market/event/category, value, label}` (POST admin) | -| DELETE | `/portfolio/exclusions/{id}` | Rimuove un'esclusione (admin) | +| POST | `/portfolio/reset` | `{bankroll, preset}`: deletes the simulated bets and starts over (admin) | +| GET | `/portfolio/bets` | Bets: `status` = `open`, `settled`, `excluded`, `all`; open ones have their exit plan (`plan`) | +| POST | `/portfolio/bets/{id}/exclude` · `/include` | Excludes or readmits a bet (admin) | +| POST | `/portfolio/bets/{id}/sell` | Sells an open bet on the book right away (admin) | +| GET · POST | `/portfolio/exclusions` | Exclusions `{kind: market/event/category, value, label}` (POST admin) | +| DELETE | `/portfolio/exclusions/{id}` | Removes an exclusion (admin) | ### Backtest -| Metodo | Path | Descrizione | +| Method | Path | Description | |---|---|---| -| GET · POST | `/backtest/runs` | Elenco dei backtest; avvio `{resolved_after, resolved_before, max_markets, min_volume, horizons, max_calls, exclude_decided, kinds}` (`kinds`: `binary` e/o `multi`) (POST admin, `409` se uno è già in corso) | -| GET | `/backtest/runs/{id}` | Avanzamento e riepilogo (metriche, calibrazione, suggerimenti) | -| GET | `/backtest/runs/{id}/cases` | Casi: `status` = `ok`, `skipped`, `all` | -| POST | `/backtest/runs/{id}/stop` · DELETE `/backtest/runs/{id}` | Ferma o elimina (admin) | -| GET · PUT | `/backtest/parameters` | `MODEL_WEIGHT_MAX`, `MIN_EDGE`, `JEV_CALIB_A`, `JEV_CALIB_B` in uso; PUT li sostituisce (admin) | -| POST | `/backtest/parameters/reset` | Torna ai valori del `.env` (admin) | +| GET · POST | `/backtest/runs` | List of backtests; start `{resolved_after, resolved_before, max_markets, min_volume, horizons, max_calls, exclude_decided, kinds}` (`kinds`: `binary` and/or `multi`) (POST admin, `409` if one is already running) | +| GET | `/backtest/runs/{id}` | Progress and summary (metrics, calibration, suggestions) | +| GET | `/backtest/runs/{id}/cases` | Cases: `status` = `ok`, `skipped`, `all` | +| POST | `/backtest/runs/{id}/stop` · DELETE `/backtest/runs/{id}` | Stops or deletes (admin) | +| GET · PUT | `/backtest/parameters` | `MODEL_WEIGHT_MAX`, `MIN_EDGE`, `JEV_CALIB_A`, `JEV_CALIB_B` in use; PUT overrides them (admin) | +| POST | `/backtest/parameters/reset` | Back to the `.env` values (admin) | -### Uso e costi +### Usage and costs -| Metodo | Path | Descrizione | +| Method | Path | Description | |---|---|---| -| GET | `/usage` | Oggi rispetto ai limiti, storico per giorno, servizio e funzione (`days`, max 90), limiti e prezzi in uso | -| PUT | `/usage/settings` | Limiti giornalieri e prezzi (admin) | -| POST | `/usage/settings/reset` | Torna ai valori del `.env` (admin) | +| GET | `/usage` | Today against the limits, history per day, service and feature (`days`, max 90), limits and prices in use | +| PUT | `/usage/settings` | Daily limits and prices (admin) | +| POST | `/usage/settings/reset` | Back to the `.env` values (admin) | -### Mercati a più esiti +### Multi-outcome markets -| Metodo | Path | Descrizione | +| Method | Path | Description | |---|---|---| -| GET | `/multi/opportunities` | Eventi con un esito sottovalutato (`min_edge`, `min_evidence`, `include_hold`), con la valutazione economica | -| GET | `/multi` | Eventi con i primi esiti e l'ultima previsione. `q` (titolo o nome di un esito), `sort` = `volume`, `edge`, `signal`, `end_date`, `news` | -| GET | `/multi/{id}` | Tutti gli esiti, notizie collegate, storico delle previsioni | -| POST | `/multi/{id}/predict` | Previsione Jev della distribuzione (admin; `422` senza notizie) | +| GET | `/multi/opportunities` | Events with an undervalued outcome (`min_edge`, `min_evidence`, `include_hold`), with the economic assessment | +| GET | `/multi` | Events with the top outcomes and the latest forecast. `q` (title or name of an outcome), `sort` = `volume`, `edge`, `signal`, `end_date`, `news` | +| GET | `/multi/{id}` | All outcomes, linked news, forecast history | +| POST | `/multi/{id}/predict` | Jev forecast of the distribution (admin; `422` without news) | -### Allerte +### Alerts -| Metodo | Path | Descrizione | +| Method | Path | Description | |---|---|---| -| GET | `/alerts` | Allerte con notizia, prezzi dopo l'allerta e movimento a favore. `kind` = `opportunities` (default) o `all` | -| GET | `/alerts/summary` | Risultati degli ultimi `days` giorni: movimento medio e quota a favore per ogni intervallo, esito dei mercati risolti | -| GET · PUT | `/alerts/settings` | Impostazioni (PUT admin) | -| POST | `/alerts/test-telegram` | Invia un messaggio di prova (admin) | -| POST | `/alerts/run` | Controlla subito le notizie nuove (admin) | +| GET | `/alerts` | Alerts with the news, the prices after the alert and the favourable move. `kind` = `opportunities` (default) or `all` | +| GET | `/alerts/summary` | Results of the last `days` days: average move and favourable share for each interval, outcome of the resolved markets | +| GET · PUT | `/alerts/settings` | Settings (PUT admin) | +| POST | `/alerts/test-telegram` | Sends a test message (admin) | +| POST | `/alerts/run` | Checks the new news right away (admin) | -Codici di errore di `/markets/{id}/predict`: `503` chiave TypeSafe mancante, `422` nessuna -notizia collegata, `409` mercato chiuso o senza prezzo, `404` mercato sconosciuto. +Error codes of `/markets/{id}/predict`: `503` TypeSafe key missing, `422` no linked news, +`409` market closed or without a price, `404` unknown market. -Esempio di risposta di `/predictions/opportunities` (valori illustrativi): +Example answer of `/predictions/opportunities` (illustrative values): ```json [ diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 0000000..c849d1d --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,174 @@ +# Configuration + +[← Back to the README](../README.md) · [All documentation](README.md) · [Italiano](it/configurazione.md) + +Every variable of the `.env` file. + +All variables are set in `.env`. Placeholder values `your_...` count as "not configured". + +
+Docker compose + +| Variable | Default | Description | +|---|---|---| +| `POSTGRES_PASSWORD` | `password` | Database password; choose it before the first start (letters and digits only) | +| `COMPOSE_PROFILES` | – | Optional services: `tunnel` (Cloudflare Tunnel), `backup` (daily dump), also together: `tunnel,backup` | +| `CLOUDFLARE_TUNNEL_TOKEN` | – | Token of the tunnel created on Cloudflare | +| `BACKUP_KEEP_DAYS` | `14` | Days of backups kept in `./backups` | +| `API_BIND` | `127.0.0.1` | Address port 8000 answers on: this machine only, or `0.0.0.0` for the home network (see [Deploy](deploy.md#local-use-and-home-network)) | + +
+ +
+Database and AI + +| Variable | Default | Description | +|---|---|---| +| `DATABASE_URL` | `postgresql+asyncpg://postgres:password@localhost:5432/postgres` | Database connection | +| `SIMILARITY_THRESHOLD` | `0.92` | Similarity above which two titles are the same news | +| `STORY_SIMILARITY_THRESHOLD` | `0.82` | Title + text similarity above which two articles tell the same story (titles rewritten by different outlets) | +| `STORY_WINDOW_HOURS` | `48` | How far back the same story is looked for | +| `EMBEDDING_MODEL` | `paraphrase-multilingual-MiniLM-L12-v2` | Embedding model (384 dimensions, multilingual: Italian news finds the market written in English). If you change it, at startup the stored vectors are recomputed in the background, newest news first | +| `TYPESAFE_API_KEY` | – | TypeSafe key. Without a key: no forecasts, heuristic classification | +| `TYPESAFE_MODEL` | `jev-latest` | Jev model | +| `SUMMARIZER_PROVIDER` | `auto` | `gemini`, `groq`, `ollama` or `auto` | +| `GEMINI_API_KEY` / `GEMINI_MODEL` | – / `gemini-2.5-flash` | Google AI Studio | +| `GROQ_API_KEY` / `GROQ_MODEL` | – / `llama-3.1-8b-instant` | Groq Cloud | +| `OLLAMA_BASE_URL` / `OLLAMA_MODEL` | `http://localhost:11434` / `qwen2.5:1.5b` | Local Ollama | + +
+ +
+Polymarket and forecasts + +| Variable | Default | Description | +|---|---|---| +| `POLYMARKET_ENABLED` | `true` | Turns market sync and linking on | +| `POLYMARKET_SYNC_LIMIT` | `200` | Maximum number of open markets followed | +| `POLYMARKET_MIN_VOLUME` | `10000` | Minimum volume (USD): excludes illiquid markets | +| `MARKET_MATCH_THRESHOLD` | `0.5` | Minimum news ↔ market match (meaning + key terms), 0–1 | +| `MARKET_CANDIDATE_MARGIN` | `0.15` | Candidates: semantic similarity ≥ threshold − margin | +| `MARKET_MATCH_TERM_WEIGHT` | `0.3` | Weight of the key terms in the match | +| `MARKET_MATCH_NO_ENTITY_PENALTY` | `0.6` | Multiplier when the news does not mention any name in the question | +| `MARKET_NEWS_WINDOW_HOURS` | `168` | Only news from the last N hours (7 days: for many markets a week of context matters) | +| `MARKET_MAX_ARTICLES` | `8` | News items passed to Jev for each forecast | +| `EVIDENCE_HALF_LIFE_HOURS` | `48` | A news item's weight halves every N hours (minimum 25 %) | +| `EVIDENCE_MIN_JEV_RELEVANCE` | `0.25` | News that Jev judged less relevant than this for a market is no longer passed to it | +| `TARGETED_NEWS_ENABLED` | `true` | Targeted Google News search for each market | +| `TARGETED_NEWS_MAX_MARKETS` | `25` | Markets searched on each run (the most traded) | +| `TARGETED_NEWS_REFRESH_HOURS` | `6` | How often to repeat the search for the same market | +| `TARGETED_NEWS_MAX_RESULTS` | `10` | News items kept per search | +| `TARGETED_NEWS_DAYS` | `7` | Only results from the last N days | +| `TARGETED_NEWS_LOCALE` | `hl=en-US&gl=US&ceid=US:en` | Language and country of the results | +| `PREDICTION_AUTO` | `false` | Automatic forecasts after every collection (each forecast is a paid call) | +| `PREDICTION_MAX_PER_RUN` | `10` | Cap on automatic forecasts per run | +| `MODEL_WEIGHT_MAX` | `0.5` | Jev's maximum weight against the market price | +| `BLEND_METHOD` | `logodds` | How Jev and price are combined: `logodds` or `linear` | +| `JEV_CALIB_A` / `JEV_CALIB_B` | `0` / `1` | Platt calibration of Jev's estimate (estimated by the backtest) | +| `JEV_SAMPLES` | `1` | Jev calls per forecast, averaged (each one is paid) | +| `MIN_EDGE` | `0.05` | Minimum edge to issue a signal | +| `MIN_EVIDENCE` | `0.5` | Minimum evidence strength to issue a signal | +| `KELLY_FRACTION` | `0.25` | Fraction of the Kelly criterion used for the stake | + +
+ +
+Server + +| Variable | Default | Description | +|---|---|---| +| `APP_LANGUAGE` | `en` | Language of the texts written without a dashboard request: Telegram alerts, news summaries, stored notes. `en` or `it`. The dashboard has its own IT/EN button | +| `INGEST_INTERVAL_MINUTES` | `60` | Pipeline interval | +| `ALERT_SCAN_MINUTES` | `10` | Quick check for alerts: sources → links → alerts, without summaries (`0` turns it off) | +| `ALERT_FOLLOWUP_MINUTES` | `5` | How often to record the price after alerts | +| `ALERT_TIMEZONE` | `Europe/Rome` | Time zone of the quiet hours | +| `TELEGRAM_BOT_TOKEN` / `TELEGRAM_CHAT_ID` | – | Bot and chat that receive the notifications | +| `PUBLIC_URL` | – | Dashboard address, for the link in notifications (e.g. `https://news.example.com`) | +| `AI_BATCH_SIZE` | `25` | News items summarised and classified on each run | +| `FEED_TIMEOUT_SECONDS` | `20` | Maximum time to download a feed | +| `FEED_MAX_BYTES` | `5000000` | Maximum size of a feed | +| `ALLOW_PRIVATE_FEEDS` | `false` | Allows feeds on internal addresses (see below) | +| `CORS_ORIGINS` | – | Allowed external origins, comma separated. The dashboard does not need any | +| `API_DOCS_ENABLED` | `true` | Publishes `/docs` and `/openapi.json`. Turn it off in production | + +
+ +
+Economic assessment and simulated portfolio + +| Variable | Default | Description | +|---|---|---| +| `POLYMARKET_CLOB_URL` | `https://clob.polymarket.com` | Public book API (read only) | +| `RISK_FREE_RATE` | `0.04` | Annual return of the risk-free alternative, the basis of the return threshold | +| `DEFAULT_FEE_BPS` | `500` | Fee rate (basis points, applied to `p × (1 − p)`) for unknown categories | +| `DEFAULT_SPREAD` | `0.02` | Spread assumed when the book is not available | +| `MODEL_PSEUDO_COUNT` | `20` | How many "observations" a Jev estimate with full evidence is worth (sets the uncertainty) | +| `PAPER_BANKROLL` | `1000` | Starting simulated capital (editable from the dashboard) | +| `PAPER_PRESET` | `bilanciato` | Starting preset: `prudente` (prudent), `bilanciato` (balanced), `aggressivo` (aggressive) | + +
+ +
+AI API limits + +Each provider has a client-side limiter: it spaces requests out, caps concurrent ones and, when +the API answers 429, pauses that provider for the time given by `Retry-After`. Set the values +of your plan (you find them in the Groq, Google AI Studio and TypeSafe consoles). + +| Variable | Default | Description | +|---|---|---| +| `GROQ_RPM` | `20` | Requests per minute to Groq | +| `GEMINI_RPM` | `10` | Requests per minute to Gemini | +| `JEV_RPM` | `30` | Requests per minute to TypeSafe Jev | +| `JEV_CONCURRENCY` | `2` | Concurrent Jev calls | +| `OLLAMA_CONCURRENCY` | `1` | Concurrent requests to local Ollama | +| `AI_MAX_WAIT_SECONDS` | `90` | Maximum wait of a background job for its turn | + +- **Jev pause:** if Jev is paused, classification stops and the remaining news is picked up + on the next run (it is not downgraded to the heuristic). A manual forecast from the dashboard + waits at most 10 seconds, then answers "try again in N seconds". +- **Groq or Gemini pause:** it moves to the next provider; with no provider available the + summary is an excerpt of the text. +- **Reasoning models:** with Groq's reasoning models (`openai/gpt-oss-20b`, + `openai/gpt-oss-120b`) the app asks for short reasoning (`reasoning_effort=low`) and limits + the answer tokens, so as not to exhaust the tokens-per-minute limit. + +
+ +
+Access + +| Variable | Default | Description | +|---|---|---| +| `SESSION_TTL_HOURS` | `168` | Maximum length of a session (7 days) | +| `SESSION_IDLE_MINUTES` | `720` | Closed after inactivity (12 hours) | +| `SESSION_COOKIE_SECURE` | `auto` | `auto` = `Secure` except on localhost over HTTP; or `true` / `false`. To open the dashboard over HTTP from another device at home you need `false` | +| `CLIENT_IP_HEADER` | – | Header with the visitor's real IP set by a trusted proxy in front of the app (`CF-Connecting-IP` behind Cloudflare Tunnel). Use it only if the app is reachable exclusively through that proxy | +| `LOGIN_MAX_ATTEMPTS` | `5` | Failed attempts per IP and username before lockout | +| `LOGIN_MAX_ATTEMPTS_PER_IP` | `20` | Failed attempts per IP, whatever the username | +| `LOGIN_WINDOW_MINUTES` | `15` | Window and length of the lockout | +| `ADMIN_USERNAME` / `ADMIN_PASSWORD` | – | Admin created at the first start if there are no users | + +
+ +**Sources.** They are managed from *Settings → Sources*. At the first start, with the sources +table empty, the `feeds.yaml` entries with `active: true` are added; the others show up among +the recommended sources. Each source can have a main topic, used as a hint by the classifier. +For safety the server refuses feeds pointing to internal networks (localhost, 10.x, +192.168.x, cloud metadata), even after a redirect: otherwise whoever adds a source could make +the server query the internal network. If you need an internal feed set +`ALLOW_PRIVATE_FEEDS=true`. + +The catalogue contains 85 sources, grouped by the topics traded on Polymarket: +- world affairs and conflicts; +- US and European politics, including polls, the Supreme Court and elections; +- economy, with primary sources such as the Fed, ECB, Bank of England, BLS, BEA and SEC; +- crypto; +- technology and AI, including the official announcements of OpenAI and Google; +- science, space, health and weather (NASA, WHO, NOAA for hurricanes); +- sport; +- culture and entertainment (awards, box office, music); +- Italian outlets. + +In *Settings* you can add them all, or by category, with «Select all». Before activating many +new sources test the feeds with «Test the feed»: RSS addresses sometimes change. diff --git a/docs/costs.md b/docs/costs.md new file mode 100644 index 0000000..620cf1a --- /dev/null +++ b/docs/costs.md @@ -0,0 +1,39 @@ +# API usage and costs + +[← Back to the README](../README.md) · [All documentation](README.md) · [Italiano](it/costi.md) + +Log of the paid calls, cost estimate and daily limits. + +Between classification, summaries, forecasts, alerts, «Assess all», backtests and +multi-outcome markets, paid calls can grow quickly. The **Usage and costs** page keeps them +under control. + +**What is recorded.** Every call to Jev, Groq and Gemini, with: +- the **feature** that made it (classification, summaries, reclassification, forecasts, + Assess all, alerts, multi-outcome, backtest); +- the input and output **tokens**; +- an **estimated cost**, computed from the prices you set: per million tokens and, for Jev, + also per call. With prices at 0, calls and tokens are counted but not the cost. + +Ollama runs locally and is not counted. + +**Daily limits.** They are set from the page (admins only) and override the `.env` values: +- `DAILY_JEV_CALL_LIMIT`: maximum number of Jev calls; +- `DAILY_AI_BUDGET_USD`: maximum estimated spend for all APIs. + +The day resets at midnight (`ALERT_TIMEZONE` time zone). Past the limit: +- forecasts, alerts, «Assess all» and backtests stop with a clear message; +- classification switches to the local heuristic and summaries to a text excerpt, so news + keeps coming in; +- a banner across the whole dashboard says so. + +At 80 % (`USAGE_WARN_SHARE`) and at 100 % of each limit a Telegram message arrives, once a +day, if Telegram is configured. + +**What the page shows.** +- **Today:** calls and spend against the limits. +- **Last 30 days:** a chart per day, by cost, calls or tokens, with the breakdown by feature + on hover. +- **Summary** by feature and by service, with the share of spend. + +The cost is an estimate: the provider's invoice is what counts. diff --git a/docs/deploy.md b/docs/deploy.md index 58a5853..b51b0e4 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -1,154 +1,153 @@ # Deploy -[← Torna al README](../README.md) · [Tutta la documentazione](README.md) +[← Back to the README](../README.md) · [All documentation](README.md) · [Italiano](it/deploy.md) -Immagini Docker per CPU e GPU, uso in locale e nella rete di casa, messa online su un VPS -con Cloudflare Tunnel. +Docker images for CPU and GPU, local and home-network use, going online on a VPS with +Cloudflare Tunnel. -## Immagini Docker: CPU o GPU +## Docker images: CPU or GPU -Ci sono due immagini: +There are two images: -| File | Per | Note | +| File | For | Notes | |---|---|---| -| `Dockerfile` (predefinito) | Server, VPS, macchine ARM, PC senza GPU NVIDIA | PyTorch solo CPU: immagine molto più piccola e build più veloce | -| `Dockerfile.cuda` | PC con GPU NVIDIA | PyTorch con CUDA; gli embedding vengono calcolati sulla GPU | +| `Dockerfile` (default) | Servers, VPSs, ARM machines, PCs without an NVIDIA GPU | CPU-only PyTorch: a much smaller image and a faster build | +| `Dockerfile.cuda` | PCs with an NVIDIA GPU | PyTorch with CUDA; embeddings are computed on the GPU | -Per usare la GPU (serve il driver NVIDIA e l'[NVIDIA Container Toolkit](https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/install-guide.html) sull'host, solo x86_64): +To use the GPU (you need the NVIDIA driver and the [NVIDIA Container Toolkit](https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/install-guide.html) on the host, x86_64 only): ```bash docker compose -f docker-compose.yml -f docker-compose.gpu.yml up --build ``` -Nel log all'avvio, `Embedding model ... on cuda:0` conferma che la GPU è in uso (`on cpu` -altrimenti). Le due immagini hanno nomi diversi (`news-aggregator-api:cpu` e `:cuda`), quindi -si può passare dall'una all'altra senza ricostruire ogni volta. Per un server la GPU non serve: -il modello è piccolo e gli articoli arrivano a gruppi. +In the startup log, `Embedding model ... on cuda:0` confirms the GPU is in use (`on cpu` +otherwise). The two images have different names (`news-aggregator-api:cpu` and `:cuda`), so you +can switch between them without rebuilding every time. A server does not need the GPU: the +model is small and articles arrive in batches. -## Uso in locale e nella rete di casa +## Local use and home network -Per usarla sul proprio computer non servono tunnel né altro: `tunnel` e `backup` sono -servizi opzionali, attivi solo se elencati in `COMPOSE_PROFILES`. Senza, il compose avvia -solo il database e l'app. +To use it on your own computer you need no tunnel or anything else: `tunnel` and `backup` are +optional services, active only when listed in `COMPOSE_PROFILES`. Without them, compose starts +only the database and the app. ```bash -cp .env.example .env # TYPESAFE_API_KEY, POSTGRES_PASSWORD e le altre chiavi +cp .env.example .env # TYPESAFE_API_KEY, POSTGRES_PASSWORD and the other keys docker compose up -d --build -docker compose exec api python -m backend.auth.cli create-user tuonome --role admin +docker compose exec api python -m backend.auth.cli create-user yourname --role admin ``` -La dashboard è su **http://localhost:8000**. Il login funziona anche in HTTP: con -`SESSION_COOKIE_SECURE=auto` il cookie di sessione non chiede HTTPS quando l'indirizzo è -`localhost` (o `127.0.0.1`). Per il backup giornaliero anche in locale: -`COMPOSE_PROFILES=backup`. +The dashboard is on **http://localhost:8000**. Sign-in works over HTTP too: with +`SESSION_COOKIE_SECURE=auto` the session cookie does not require HTTPS when the address is +`localhost` (or `127.0.0.1`). For the daily backup locally too: `COMPOSE_PROFILES=backup`. -**Da un altro dispositivo di casa** (telefono, tablet, un altro PC), per esempio su -`http://192.168.1.10:8000`, servono due impostazioni in `.env`: +**From another device at home** (phone, tablet, another PC), for example on +`http://192.168.1.10:8000`, you need two settings in `.env`: -| Variabile | Valore | Perché | +| Variable | Value | Why | |---|---|---| -| `API_BIND` | `0.0.0.0` | Di base la porta 8000 risponde solo alla macchina su cui gira l'app | -| `SESSION_COOKIE_SECURE` | `false` | Con un indirizzo diverso da `localhost`, `auto` marca il cookie come `Secure`: in HTTP il browser non lo salva e l'accesso non riesce: la pagina di login lo segnala e indica questa impostazione | +| `API_BIND` | `0.0.0.0` | By default port 8000 only answers the machine the app runs on | +| `SESSION_COOKIE_SECURE` | `false` | With an address other than `localhost`, `auto` marks the cookie as `Secure`: over HTTP the browser does not save it and sign-in fails; the sign-in page says so and points to this setting | -Poi `docker compose up -d` per applicarle. L'indirizzo del computer si trova con -`ip addr` (Linux), `ipconfig` (Windows) o nelle impostazioni di rete (macOS); se non -risponde, controlla che il firewall del computer lasci passare la porta 8000. +Then `docker compose up -d` to apply them. You find the computer's address with `ip addr` +(Linux), `ipconfig` (Windows) or in the network settings (macOS); if it does not answer, check +that the computer's firewall lets port 8000 through. > [!WARNING] -> Senza HTTPS password e sessione viaggiano in chiaro: va bene solo su una rete di casa -> fidata, mai su una rete pubblica. Per accedere da fuori usa -> [Cloudflare Tunnel](#deploy-su-un-vps-con-cloudflare-tunnel): funziona anche con l'app -> sul computer di casa, e in quel caso rimetti `SESSION_COOKIE_SECURE=auto` e -> `API_BIND=127.0.0.1`. +> Without HTTPS the password and the session travel in clear text: fine only on a trusted home +> network, never on a public one. To get in from outside use +> [Cloudflare Tunnel](#deploy-on-a-vps-with-cloudflare-tunnel): it also works with the app on +> your home computer, and in that case set `SESSION_COOKIE_SECURE=auto` and +> `API_BIND=127.0.0.1` again. -## Deploy su un VPS con Cloudflare Tunnel +## Deploy on a VPS with Cloudflare Tunnel -Il modo più economico per tenerla online: un piccolo VPS (per esempio OVH VPS-1 o Hetzner: -2 vCore, 4 GB di RAM bastano) con Docker, e **Cloudflare Tunnel** per l'HTTPS. Il tunnel -esce dal server verso Cloudflare: nessuna porta da aprire, nessun certificato da gestire, -l'indirizzo IP del server resta nascosto. Serve un dominio gestito da Cloudflare (piano -gratuito); il tunnel è gratuito. +The cheapest way to keep it online: a small VPS (for example OVH VPS-1 or Hetzner: 2 vCores, +4 GB of RAM are enough) with Docker, and **Cloudflare Tunnel** for HTTPS. The tunnel goes out +from the server to Cloudflare: no ports to open, no certificates to manage, the server's IP +address stays hidden. You need a domain managed by Cloudflare (free plan); the tunnel is free. ```mermaid flowchart LR U[Browser] -- HTTPS --> CF[Cloudflare] - CF -- tunnel in uscita --> T[cloudflared] + CF -- outbound tunnel --> T[cloudflared] subgraph VPS T --> A[api :8000] A --> D[(Postgres)] - B[backup giornaliero] --> D + B[daily backup] --> D end ``` -**1. Il server.** Ubuntu 24.04, accesso SSH con chiave. Firewall con la sola porta SSH aperta: +**1. The server.** Ubuntu 24.04, SSH access with a key. A firewall with only the SSH port open: ```bash sudo apt update && sudo apt upgrade -y sudo ufw allow OpenSSH && sudo ufw enable ``` -La porta 8000 dell'app ascolta solo su `127.0.0.1` (`API_BIND`): Docker aggira `ufw` per le -porte pubblicate, per questo non viene pubblicata sulla rete. +The app's port 8000 only listens on `127.0.0.1` (`API_BIND`): Docker bypasses `ufw` for +published ports, which is why it is not published on the network. -**2. Docker e il codice.** +**2. Docker and the code.** ```bash curl -fsSL https://get.docker.com | sh -sudo usermod -aG docker $USER # poi esci e rientra +sudo usermod -aG docker $USER # then log out and back in git clone https://github.com/botta0oss/News_Aggregator.git && cd News_Aggregator cp .env.example .env ``` -**3. Il tunnel.** Nella dashboard di Cloudflare: *Zero Trust → Networks → Tunnels → Create a -tunnel → Cloudflared*, dagli un nome e copia il **token** (la lunga stringa dopo `--token` nel -comando di installazione proposto: non serve eseguirlo, `cloudflared` gira già nel compose). -Poi aggiungi un *Public hostname*: sottodominio a scelta (es. `news.tuodominio.it`), tipo -**HTTP**, URL **`api:8000`**. +**3. The tunnel.** In the Cloudflare dashboard: *Zero Trust → Networks → Tunnels → Create a +tunnel → Cloudflared*, give it a name and copy the **token** (the long string after `--token` +in the suggested install command: you do not need to run it, `cloudflared` already runs in +compose). Then add a *Public hostname*: a subdomain of your choice (e.g. `news.yourdomain.com`), +type **HTTP**, URL **`api:8000`**. -**4. Il file `.env`.** Oltre alle chiavi delle API: +**4. The `.env` file.** Besides the API keys: -| Variabile | Valore | +| Variable | Value | |---|---| -| `POSTGRES_PASSWORD` | Una password casuale, prima del primo avvio: `openssl rand -hex 24` | +| `POSTGRES_PASSWORD` | A random password, before the first start: `openssl rand -hex 24` | | `COMPOSE_PROFILES` | `tunnel,backup` | -| `CLOUDFLARE_TUNNEL_TOKEN` | Il token del passo 3 | -| `CLIENT_IP_HEADER` | `CF-Connecting-IP` (IP reale dei visitatori per i limiti di accesso) | -| `PUBLIC_URL` | `https://news.tuodominio.it` (link nelle notifiche Telegram) | +| `CLOUDFLARE_TUNNEL_TOKEN` | The token from step 3 | +| `CLIENT_IP_HEADER` | `CF-Connecting-IP` (visitors' real IP for the sign-in limits) | +| `PUBLIC_URL` | `https://news.yourdomain.com` (links in the Telegram notifications) | | `API_DOCS_ENABLED` | `false` | -| `ADMIN_USERNAME`, `ADMIN_PASSWORD` | Il primo amministratore; dopo il primo avvio togli la password dal file | -| `DAILY_JEV_CALL_LIMIT`, `DAILY_AI_BUDGET_USD` | Un tetto alla spesa per le chiamate a pagamento | +| `ADMIN_USERNAME`, `ADMIN_PASSWORD` | The first administrator; after the first start remove the password from the file | +| `DAILY_JEV_CALL_LIMIT`, `DAILY_AI_BUDGET_USD` | A cap on the spend for paid calls | +| `APP_LANGUAGE` | `en` or `it`: language of Telegram alerts and news summaries | -**5. Avvio.** +**5. Start.** ```bash -docker compose up -d --build # la prima build scarica il modello: qualche minuto -docker compose logs -f api tunnel # attendi "Application startup complete" e "Registered tunnel connection" +docker compose up -d --build # the first build downloads the model: a few minutes +docker compose logs -f api tunnel # wait for "Application startup complete" and "Registered tunnel connection" ``` -La dashboard è su `https://news.tuodominio.it`. Per un livello di protezione in più, *Zero Trust -→ Access* può chiedere un codice via email prima ancora della pagina di login (gratuito fino a -50 utenti). +The dashboard is on `https://news.yourdomain.com`. For an extra layer of protection, +*Zero Trust → Access* can ask for an email code even before the sign-in page (free up to 50 +users). -**Aggiornare** all'ultima versione (dati e backup restano): +**Update** to the latest version (data and backups stay): ```bash ./scripts/update.sh ``` -**Backup.** Con il profilo `backup` ogni giorno viene salvato un dump in `./backups`, tenuto per -`BACKUP_KEEP_DAYS` giorni. È sullo stesso disco del database: copialo anche fuori dal server -(per esempio con `rclone` su Cloudflare R2, o scaricandolo con `scp`), oppure attiva i backup -del VPS offerti dal provider. Per ripristinarne uno: +**Backups.** With the `backup` profile a dump is saved every day in `./backups`, kept for +`BACKUP_KEEP_DAYS` days. It is on the same disk as the database: copy it off the server too +(for example with `rclone` to Cloudflare R2, or downloading it with `scp`), or turn on the VPS +backups offered by the provider. To restore one: ```bash docker compose stop api -docker compose exec -T db pg_restore -U postgres -d postgres --clean --if-exists < backups/newsagg-AAAAMMGG-HHMM.dump +docker compose exec -T db pg_restore -U postgres -d postgres --clean --if-exists < backups/newsagg-YYYYMMDD-HHMM.dump docker compose start api ``` -**Se qualcosa non va.** -- *Errore 502 o 1033 da Cloudflare*: l'app non risponde ancora (al primo avvio carica il - modello) o il tunnel non è connesso: `docker compose logs api tunnel`. -- *Il tunnel non si connette*: token sbagliato, o nel *Public hostname* l'URL non è `api:8000`. -- *Password del database cambiata dopo il primo avvio*: il database tiene quella vecchia. - Aggiornala anche lì: `docker compose exec db psql -U postgres -c "ALTER USER postgres PASSWORD 'nuova'"`. +**If something goes wrong.** +- *Error 502 or 1033 from Cloudflare*: the app is not answering yet (at the first start it + loads the model) or the tunnel is not connected: `docker compose logs api tunnel`. +- *The tunnel does not connect*: wrong token, or in the *Public hostname* the URL is not `api:8000`. +- *Database password changed after the first start*: the database keeps the old one. Update + it there too: `docker compose exec db psql -U postgres -c "ALTER USER postgres PASSWORD 'new'"`. diff --git a/docs/development.md b/docs/development.md new file mode 100644 index 0000000..ad4d07f --- /dev/null +++ b/docs/development.md @@ -0,0 +1,123 @@ +# Development and tests + +[← Back to the README](../README.md) · [All documentation](README.md) · [Italiano](it/sviluppo.md) + +Tests, CI and code structure. + +Tests need PostgreSQL with pgvector. TypeSafe and Polymarket are mocked at the HTTP level, so +no keys or network are needed. + +```bash +pip install -r requirements.txt -r requirements-dev.txt +export TEST_DATABASE_URL=postgresql+asyncpg://postgres:password@localhost:5432/newsagg +pytest +``` + +Tests do not use the embedding model, so `sentence-transformers` (and PyTorch) can be skipped +if you only need to run the tests. + +**CI.** On GitHub every push to `main` and every pull request start +`.github/workflows/tests.yml`: +- **backend:** all the tests, on Postgres with pgvector (`pgvector/pgvector:pg16`) and without + PyTorch; +- **frontend:** syntax check of the JavaScript modules with `node --check`. + +In CI an unreachable database fails the tests instead of skipping them. + +⚠️ The end-to-end test **drops and recreates every table** of the database given by +`TEST_DATABASE_URL`: always use a dedicated database. + +| File | Contents | +|---|---| +| `tests/test_pipeline.py` | Composite score, heuristic, Jev evaluator, summaries | +| `tests/test_forecast.py` | Platt calibration, log-odds pooling (multi-outcome too), Kelly, signals, Brier score | +| `tests/test_polymarket.py` | Parsing and filtering of Gamma markets, final outcome (UMA oracle) and 50-50 | +| `tests/test_dedup.py` | Same story from different outlets grouped, confirmations counted per outlet | +| `tests/test_reembed.py` | Change of embedding model (vectors recomputed), historical outlet reliability | +| `tests/test_markets_e2e.py` | Full flow: collection → markets → forecast → API → resolution | +| `tests/test_auth.py` | Passwords, cookies, CSRF, roles, attempt limit, expiry, sign-out, security headers | +| `tests/test_sources.py` | Fetcher (HTML cleaning, redirects, internal network blocking, limits), catalogue, migrations, sources API | +| `tests/test_search.py` | Search on title, text and summary, prefixes, syntax, highlights, filters, index use | +| `tests/test_economics.py` | Book, `p × (1 − p)` fees, maximum price net of the fee, estimated book in levels, Kelly on the book, uncertainty, annualisation, verdicts and preset limits | +| `tests/test_markets_sort.py` | Market sorting on every field and direction, missing values last, stable pagination | +| `tests/test_ratelimit.py` | Limiter (spacing, pause, concurrency), Groq under rate limit and with reasoning models, queue that resumes, 429 on manual forecasts, files without cache | +| `tests/test_portfolio.py` | Automatic bets, exclusions, closing (50-50 too), automatic and manual selling, plan in the API, fees per category, closing price (CLV), profit and loss, curve, API and permissions | +| `tests/test_strategy.py` | Prices at which to buy and sell, actions (buy, wait, avoid, hold, sell), reasons, confidence | +| `tests/test_matching.py` | Key terms, synonyms, match, evidence ranking, targeted Google News search | +| `tests/test_multi.py` | Multi-outcome events: sync, distribution, YES/NO signal, economics, alerts, arbitrage | +| `tests/test_alerts.py` | Detection, immediate assessment, Telegram, quiet hours, prices after the alert, settings and permissions | +| `tests/test_backtest.py` | Planning, news without hindsight (archive too), metrics, bootstrap, parameters verified on recent markets | +| `tests/test_bulk_predict.py` | «Assess all with Jev»: full flow, waiting on rate limits, stop after repeated errors | +| `tests/test_usage.py` | Call log, estimated costs, daily limits and warnings | +| `tests/test_i18n.py` | English and Italian texts: request language (`X-Lang`), `APP_LANGUAGE`, plans in English, every dashboard text translated | + +The tests run with `APP_LANGUAGE=it` (set in `tests/conftest.py`), so most of them check the +Italian texts; `tests/test_i18n.py` covers English. + +## Translations + +- **Dashboard.** Every visible text goes through `t()` from `frontend/i18n.js`, written in + Italian: `t("Prezzo {0}", value)`. `frontend/i18n-en.js` maps each Italian text to English, + with the same `{0}` placeholders. A missing text falls back to Italian and is collected in + `window.__i18nMissing`; `tests/test_i18n.py` fails if a `t("…")` has no translation. +- **Server.** Texts for people are written as `tr(italian, english)` (`backend/i18n.py`). The + language comes from the request's `X-Lang` header, or `APP_LANGUAGE` for background work + (alerts, summaries, stored notes); `dec()` and `dollars()` format numbers accordingly. + +## Project structure + +``` +backend/ +├── main.py # FastAPI app, startup and shutdown, request language +├── config.py # settings from .env +├── i18n.py # language of the texts (X-Lang, APP_LANGUAGE), tr(it, en) +├── overrides.py # parameters changed from the dashboard (e.g. after a backtest) +├── auth/ # passwords, sessions, roles, attempt limit, user CLI +├── ai/ +│ ├── jev.py # shared TypeSafe client +│ ├── typesafe_evaluator.py # news classification and scores +│ ├── summarizer.py # Gemini / Groq / Ollama summaries +│ ├── ratelimit.py # rate limits per service +│ └── usage.py # usage and cost of calls, daily limits +├── ingestor/ +│ ├── fetcher.py # safe download and cleaning of RSS/Atom feeds +│ ├── sources.py # catalogue (feeds.yaml) and source validation +│ ├── deduplicator.py # URL hash and embeddings +│ ├── reembed.py # recomputing vectors when the model changes +│ └── scheduler.py # periodic pipeline and reclassification +├── markets/ +│ ├── polymarket.py # Gamma and CLOB client (read only) +│ ├── service.py # sync, news linking, Jev forecasts +│ ├── matching.py # key terms, match and evidence ranking +│ ├── targeted.py # targeted Google News search per market +│ ├── forecast.py # Platt calibration, log-odds pooling, edge, Kelly, Brier +│ ├── calibration.py # results on resolved markets and closing price of signals +│ └── bulk.py # «Assess all with Jev» in the background +├── betting/ +│ ├── profiles.py # risk presets +│ ├── fees.py # Polymarket fees per category +│ ├── economics.py # economic assessment (pure functions) +│ ├── strategy.py # what to do: action, orders, prices, reasons (pure functions) +│ ├── plans.py # data for the strategy (position, news, book) +│ ├── clv.py # closing line value +│ └── portfolio.py # simulated portfolio: buys, sells, closing +├── multi/ +│ ├── service.py # multi-outcome events: sync, news, distribution forecast +│ └── arbitrage.py # sure gain buying every YES or every NO +├── backtest/ +│ ├── engine.py # rebuilding the past: historical price, news of the time, Jev +│ └── analysis.py # metrics, intervals, suggested parameters and their check +├── alerts/ +│ ├── service.py # detection, immediate assessment, prices after the alert +│ └── telegram.py # Telegram notifications +├── db/ # SQLAlchemy models, queries and idempotent migrations +└── api/ # FastAPI schemas and routes +frontend/ # dashboard with no build step: app.js, ui.js, charts.js, explain.js, + # economics.js, strategy.js, i18n.js + i18n-en.js, views/ (one per page) +docs/ # documentation (English; Italian in docs/it) and screenshots +scripts/ +├── check_env.py # checks keys and database +└── update.sh # update on the server +Dockerfile · Dockerfile.cuda · docker-compose.yml · docker-compose.gpu.yml +feeds.yaml # catalogue of recommended sources +``` diff --git a/docs/guide.md b/docs/guide.md new file mode 100644 index 0000000..9d9e81a --- /dev/null +++ b/docs/guide.md @@ -0,0 +1,104 @@ +# Dashboard guide + +[← Back to the README](../README.md) · [All documentation](README.md) · [Italiano](it/guida.md) + +The dashboard's pages, the recommended workflow and what to do if it does not open. + +## Dashboard + +The web interface is served by the same app on `/`: HTML, CSS and JavaScript with no +dependencies and no build step, in the `frontend/` folder. + +**Navigation.** Sections are grouped by what you are doing: *Signals* (Opportunities, +Alerts), *Markets* (Yes / No, Multi-outcome), *News*, *Results* (Portfolio, Backtest, +Calibration). Settings, Usage and costs, «How it works» and the account are at the bottom. +- **From 1024 px up:** a side menu that can be collapsed to icons only; the choice is + remembered. At the top, a status line (latest news, open markets, Jev on) and the «Update» + menu with the news and market updates (admins only). +- **On tablets and phones:** a bottom bar with the four most used sections and «More» for + all the others. +- The number on «Alerts» is the opportunities of the last 24 hours. + +**Language.** The **IT / EN** button at the top right, next to the theme button, switches the +whole dashboard between Italian and English. The choice is remembered in the browser; on the +first visit the dashboard follows the browser's language. Numbers, dates and amounts follow the +language too (`1,5 $` / `$1.5`), and so do the texts the server writes for the dashboard +(plans, reasons, error messages). + +| Section | What it shows | +|---|---| +| **Opportunities** | Markets with an active signal, sorted by edge: price, Jev estimate and blended probability on the same 0–100 % scale, suggested stake and evidence strength. Filters for minimum edge and evidence. | +| **Markets** | Table of markets with search and sorting (click the columns or the «Sort by» menu): price in cents, volume, liquidity, end date with the days left, linked news, latest signal and edge. | +| **Multi-outcome** | Events with several possible answers (elections, leagues, awards) as a distribution: a price bar for each outcome, Jev's estimate and the blend, edge per outcome, a signal on the outcome furthest from its price (YES if undervalued, NO if overvalued), an arbitrage badge. | +| **Market detail** | The **What to do** card (action, limit orders, price scale, reasons for and against, exit plan, the numbers of the bet), the latest forecast and a button to ask for a new one, history (price against blend), linked news with relevance and impact, resolution rules. | +| **Portfolio** | Simulated portfolio: value, realised and unrealised profits, closing price, capital curve, open positions with their plan (hold or sell), closed or sold bets, risk presets, automatic buys and sells, exclusions. | +| **News** | Search in the news (title, text, summary) with highlighted words; filters for source, period, region, category, relevance to markets, opinions; sorting by relevance, score or date. | +| **Alerts** | Latest alerts with the news, the price at the alert and the favourable move after 15 minutes, 1, 6 and 24 hours; overall results; settings (categories, thresholds, daily limit, quiet hours, Telegram test). | +| **Backtest** | Jev on markets already resolved, with the news and the price of the time: Brier against the price, by horizon and category, calibration, simulated bets, suggested parameters to apply. | +| **Calibration** | Brier score of price, Jev and blend on resolved markets with a confidence interval, and the closing price of the signals (did the price move the right way?). | +| **Usage and costs** | Calls to the paid APIs per day and per feature, estimated cost, editable daily limits. | +| **How it works** | The method step by step with the server's real parameters, a worked example and a glossary. | +| **Settings** | Sources: add (with a feed test before saving), edit, activate/deactivate, update now, delete; recommended sources from the catalogue; reclassification; forecast parameters (read only). | + +

+ + + The What to do card in a market's detail page + +

+ +In a market's detail page the **Why this signal** card shows the full calculation on the +numbers of that forecast: Jev estimate, weight, blended probability, edge, checks passed and +stake. Technical terms have a definition on hover (ⓘ). + +Search accepts quoted phrases, `-word` to exclude and `or` for alternatives; the last word +also works as a prefix. The link `#/notizie?q=...` reopens the same search. + +The «Update» menu starts the news and market updates. The page refreshes by itself while the +work goes on in the background. + +**Assess all with Jev** (in Opportunities and Markets, admins only) asks for a forecast for +every open market with linked recent news. Before starting it shows how many paid calls are +needed and how long they take with the `JEV_RPM` limit. You can assess all markets or only +those never assessed or with news newer than the latest forecast. + +The work runs in the background: you can close the page and find the progress when you come +back. It refreshes prices and links first, so the edge is computed on the current price. If +Jev answers with a rate limit, it waits and resumes from the same market. It stops by itself +after 3 consecutive errors, for example with no connection or an invalid key, and you can stop +it whenever you want. Simulated bets follow the portfolio rules. + +Design choices: +- **Dark theme by default**, with a light theme from the button at the top. The choice is remembered. +- **Fixed colours for each series in every chart:** price orange, Jev teal, blend blue. Green and red are reserved for signals and always come with an icon and text. +- **Palette checked** for colour blindness on both themes. +- **Distinct shapes:** circle and diamond, solid and dashed line. Every chart has a legend with values and an alternative table. +- **Numbers in a monospace font** (Fira Code) and prices in cents, as on Polymarket. +- **Accessibility:** keyboard navigable, 44 px touch targets, respects reduced motion, no horizontal scrolling from 320 px up. + +## Recommended workflow + +1. Start the app: the first collection and the market sync begin right away. +2. See which markets have linked news: + `GET /markets?only_linked=true` +3. Check that the news is relevant: `GET /markets/{id}`. If the links are noisy raise + `MARKET_MATCH_THRESHOLD`, if there are too few lower it. In the market detail page + «Search news» runs the targeted search right away. +4. Ask for a forecast on the markets you care about (`POST /markets/{id}/predict`), + or on all of them with **Assess all with Jev** (`POST /markets/predict-all`). +5. Look at the opportunities: `GET /predictions/opportunities?min_edge=0.08` +6. When the results are convincing, turn on `PREDICTION_AUTO=true`, keeping an eye on costs + with `PREDICTION_MAX_PER_RUN`. +7. Let the simulated portfolio run for dozens of resolved markets. Before using real money, + check that the actual result is positive and close to the expected one. + +## If the dashboard does not open + +If the page stays empty with only the logo, the app's JavaScript did not start. After 8 +seconds the page says so and offers to reload it. + +1. **Hard reload** (Ctrl+F5 or Cmd+Shift+R). After an update the browser may have old files + in its cache; the dashboard files are now served with `Cache-Control: no-cache`, so from + the next update on it should not happen again. +2. **If it persists**, open the browser's developer tools (F12 → Console and Network) and + check the container logs: a request that does not answer points to a stuck server. diff --git a/docs/images/backtest-dark.png b/docs/images/backtest-dark.png index fc0acf8..c801963 100644 Binary files a/docs/images/backtest-dark.png and b/docs/images/backtest-dark.png differ diff --git a/docs/images/backtest-light.png b/docs/images/backtest-light.png index 9687da2..067d239 100644 Binary files a/docs/images/backtest-light.png and b/docs/images/backtest-light.png differ diff --git a/docs/images/it/backtest-dark.png b/docs/images/it/backtest-dark.png new file mode 100644 index 0000000..6681ee9 Binary files /dev/null and b/docs/images/it/backtest-dark.png differ diff --git a/docs/images/it/backtest-light.png b/docs/images/it/backtest-light.png new file mode 100644 index 0000000..fbd9a4a Binary files /dev/null and b/docs/images/it/backtest-light.png differ diff --git a/docs/images/it/multi-dark.png b/docs/images/it/multi-dark.png new file mode 100644 index 0000000..e78f9f1 Binary files /dev/null and b/docs/images/it/multi-dark.png differ diff --git a/docs/images/it/multi-light.png b/docs/images/it/multi-light.png new file mode 100644 index 0000000..99730e4 Binary files /dev/null and b/docs/images/it/multi-light.png differ diff --git a/docs/images/it/overview-dark.png b/docs/images/it/overview-dark.png new file mode 100644 index 0000000..4984a49 Binary files /dev/null and b/docs/images/it/overview-dark.png differ diff --git a/docs/images/it/overview-light.png b/docs/images/it/overview-light.png new file mode 100644 index 0000000..46e0b24 Binary files /dev/null and b/docs/images/it/overview-light.png differ diff --git a/docs/images/it/portfolio-dark.png b/docs/images/it/portfolio-dark.png new file mode 100644 index 0000000..4775e63 Binary files /dev/null and b/docs/images/it/portfolio-dark.png differ diff --git a/docs/images/it/portfolio-light.png b/docs/images/it/portfolio-light.png new file mode 100644 index 0000000..34cb70f Binary files /dev/null and b/docs/images/it/portfolio-light.png differ diff --git a/docs/images/it/strategy-dark.png b/docs/images/it/strategy-dark.png new file mode 100644 index 0000000..9c6f394 Binary files /dev/null and b/docs/images/it/strategy-dark.png differ diff --git a/docs/images/it/strategy-light.png b/docs/images/it/strategy-light.png new file mode 100644 index 0000000..242b6f3 Binary files /dev/null and b/docs/images/it/strategy-light.png differ diff --git a/docs/images/multi-dark.png b/docs/images/multi-dark.png index ef39740..312c82f 100644 Binary files a/docs/images/multi-dark.png and b/docs/images/multi-dark.png differ diff --git a/docs/images/multi-light.png b/docs/images/multi-light.png index 3f6e037..e5e4a68 100644 Binary files a/docs/images/multi-light.png and b/docs/images/multi-light.png differ diff --git a/docs/images/overview-dark.png b/docs/images/overview-dark.png index d4631a4..81fa3d5 100644 Binary files a/docs/images/overview-dark.png and b/docs/images/overview-dark.png differ diff --git a/docs/images/overview-light.png b/docs/images/overview-light.png index fea60eb..eb0c106 100644 Binary files a/docs/images/overview-light.png and b/docs/images/overview-light.png differ diff --git a/docs/images/portfolio-dark.png b/docs/images/portfolio-dark.png index bcf9d5c..55613a4 100644 Binary files a/docs/images/portfolio-dark.png and b/docs/images/portfolio-dark.png differ diff --git a/docs/images/portfolio-light.png b/docs/images/portfolio-light.png index 1260916..04c099f 100644 Binary files a/docs/images/portfolio-light.png and b/docs/images/portfolio-light.png differ diff --git a/docs/images/strategy-dark.png b/docs/images/strategy-dark.png index a99dc61..35208e9 100644 Binary files a/docs/images/strategy-dark.png and b/docs/images/strategy-dark.png differ diff --git a/docs/images/strategy-light.png b/docs/images/strategy-light.png index 6da7df0..8233d95 100644 Binary files a/docs/images/strategy-light.png and b/docs/images/strategy-light.png differ diff --git a/docs/it/README.md b/docs/it/README.md new file mode 100644 index 0000000..23ba0da --- /dev/null +++ b/docs/it/README.md @@ -0,0 +1,17 @@ +# Documentazione + +[← Torna al README](../../README.it.md) · [English](../README.md) + +| | Pagina | Contenuto | +|---|---|---| +| 🧭 | [Guida alla dashboard](guida.md) | Le pagine, il flusso di lavoro consigliato, cosa fare se la dashboard non si apre | +| 🎯 | [Il metodo](metodo.md) | Raccolta, collegamento notizie–mercati, previsione, mercati a più esiti | +| 💶 | [Strategia e portafoglio](strategia.md) | Valutazione economica, cosa fare e quando vendere, portafoglio simulato | +| 🔔 | [Allerte](allerte.md) | Notifiche Telegram e misura del loro anticipo sul prezzo | +| 📊 | [Backtest e calibrazione](verifica.md) | Quanto fidarsi delle previsioni, sul passato e sul presente | +| 💳 | [Uso e costi](costi.md) | Chiamate a pagamento, stima dei costi, limiti giornalieri | +| ⚙️ | [Configurazione](configurazione.md) | Tutte le variabili di `.env` | +| 🔒 | [Accesso e sicurezza](sicurezza.md) | Utenti e ruoli, sessioni, protezioni | +| 🚀 | [Deploy](deploy.md) | Immagini CPU/GPU, uso in locale e nella rete di casa, VPS con Cloudflare Tunnel, backup | +| 🔌 | [API](api.md) | Gli endpoint REST | +| 🛠️ | [Sviluppo e test](sviluppo.md) | Test, CI, struttura del codice | diff --git a/docs/allerte.md b/docs/it/allerte.md similarity index 92% rename from docs/allerte.md rename to docs/it/allerte.md index ad3274c..73f1d30 100644 --- a/docs/allerte.md +++ b/docs/it/allerte.md @@ -1,6 +1,6 @@ # Allerte notizie–prezzo -[← Torna al README](../README.md) · [Tutta la documentazione](README.md) +[← Torna al README](../../README.it.md) · [Tutta la documentazione](README.md) · [English](../alerts.md) Notifiche Telegram quando una notizia fresca muove la stima prima del prezzo. @@ -29,6 +29,7 @@ ultime 3 ore, vengono saltati. le regole del portafoglio. 4. Se conviene arriva un messaggio Telegram con la notizia, il prezzo, la stima, l'edge, la puntata e il prezzo massimo. Nelle ore silenziose il messaggio arriva senza suono. + I messaggi sono scritti in `APP_LANGUAGE` (inglese di default, `it` per l'italiano). 5. Ogni `ALERT_FOLLOWUP_MINUTES` minuti viene registrato il prezzo 15 minuti, 1, 6 e 24 ore dopo l'allerta. diff --git a/docs/it/api.md b/docs/it/api.md new file mode 100644 index 0000000..e7864a6 --- /dev/null +++ b/docs/it/api.md @@ -0,0 +1,157 @@ +# API + +[← Torna al README](../../README.it.md) · [Tutta la documentazione](README.md) · [English](../api.md) + +Gli endpoint REST dell'app. + + +Documentazione interattiva completa su `/docs` (se `API_DOCS_ENABLED=true`). Tutti gli +endpoint richiedono una sessione; quelli `POST` anche l'header `X-CSRF-Token`, e quelli che +avviano lavori o chiamate a pagamento (`/ingest`, `/markets/sync`, `/markets/{id}/predict`, +`/markets/predict-all`) il ruolo `admin`. + +**Lingua.** Con `X-Lang: it` o `X-Lang: en` i testi scritti dal server (piani, motivi, messaggi +d'errore, nomi dei preset) arrivano in quella lingua; senza, il server usa `APP_LANGUAGE`. La +dashboard lo invia a ogni richiesta. + +### Accesso + +| Metodo | Path | Descrizione | +|---|---|---| +| POST | `/auth/login` | `{username, password}` → cookie di sessione, utente e token CSRF | +| POST | `/auth/logout` | Chiude la sessione corrente | +| GET | `/auth/me` | Utente corrente e token CSRF | +| POST | `/auth/password` | `{current_password, new_password}`; chiude le altre sessioni | + +### Notizie + +| Metodo | Path | Descrizione | +|---|---|---| +| GET | `/articles` | Notizie con ricerca, filtri e ordinamento | +| GET | `/articles/{id}` | Dettaglio di una notizia | +| GET | `/categories` | Numero di notizie per categoria | +| POST | `/ingest` | Avvia subito la pipeline completa (admin) | +| POST | `/ingest/reclassify` | Riclassifica le notizie salvate (admin, `limit` fino a 1000) | + +`/articles` accetta: +- **ricerca:** `q` (testo), `scope` (`all` o `title`), `sort` (`relevance`, `score`, `recent`); +- **filtri:** `category`, `region`, `source_id`, `since_hours`, `min_market_relevance`, `hide_opinion`; +- **paginazione:** `limit`, `offset`; +- **pesi del punteggio:** `w_authority`, `w_tech`, `w_urgency`, `w_clickbait`, con le soglie `max_clickbait`, `min_authority`. + +Con `q` ogni notizia ha anche `title_highlight` e `snippet`, con le parole trovate racchiuse +tra i caratteri `\u0002` e `\u0003`. + +```bash +curl -b cookie.txt "localhost:8000/articles?q=fed%20rate%20cut&since_hours=72&min_market_relevance=0.5" +``` + +### Fonti + +| Metodo | Path | Descrizione | +|---|---|---| +| GET | `/sources` | Fonti con stato dell'ultimo aggiornamento e numero di notizie | +| POST | `/sources` | Aggiunge una fonte `{name, url, category_hint, active}` (admin) | +| PATCH | `/sources/{id}` | Modifica nome, indirizzo, argomento o attivazione (admin) | +| DELETE | `/sources/{id}` | Elimina la fonte; se ha notizie serve `delete_articles=true` (admin) | +| POST | `/sources/{id}/fetch` | Scarica subito le notizie di questa fonte (admin) | +| POST | `/sources/test` | Prova un feed senza salvarlo: titolo, numero di notizie, esempi (admin) | +| GET | `/sources/catalog` | Fonti consigliate da `feeds.yaml`, con quelle già aggiunte | +| POST | `/sources/catalog` | Aggiunge fonti dal catalogo `{urls: [...]}` (admin) | + +### Mercati e previsioni + +| Metodo | Path | Descrizione | +|---|---|---| +| POST | `/markets/sync` | Sync mercati + collegamento notizie (+ previsioni se `PREDICTION_AUTO`) | +| GET | `/markets` | Mercati con ultima previsione. Filtri: `q`, `only_linked`, `include_closed`. Ordinamento: `sort` = `volume`, `end_date`, `price`, `signal` (ultima previsione), `edge`, `news`, `liquidity`, `question`; `order` = `asc`/`desc` (default sensato per ogni campo, valori mancanti sempre in fondo) | +| GET | `/markets/{id}` | Notizie collegate (rilevanza e impatto) e storico previsioni | +| POST | `/markets/{id}/predict` | Previsione Jev immediata (aggiorna prima il prezzo) | +| POST | `/markets/{id}/search-news` | Ricerca mirata su Google News per questo mercato; restituisce `{query, added, linked}` (admin) | +| POST | `/markets/predict-all` | Avvia in background la previsione Jev su tutti i mercati aperti con notizie recenti. `only_new=true`: solo mai valutati o con notizie nuove; `refresh_first=false`: salta l'aggiornamento dei prezzi. `409` se è già in corso (admin) | +| GET | `/markets/predict-all` | Avanzamento (`total`, `done`, `skipped`, `failed`, `current`, `message`) e mercati valutabili `eligible: {all, new}` (admin) | +| POST | `/markets/predict-all/stop` | Interrompe dopo il mercato in corso (admin) | +| GET | `/predictions/opportunities` | Mercati con edge maggiore. Filtri: `min_edge`, `min_evidence`, `include_hold` | +| GET | `/predictions/calibration` | Brier score sui mercati risolti | +| GET | `/status` | Configurazione (Jev attivo, soglie) e contatori per la dashboard | +| GET | `/markets/{id}/economics` | Valutazione economica dal vivo dell'ultima previsione e piano (`strategy`: azione, ordini, prezzi, motivi; `preset` opzionale) | +| POST | `/markets/{id}/paper-bet` | Aggiunge subito la scommessa simulata, se conviene (admin) | + +### Portafoglio simulato + +| Metodo | Path | Descrizione | +|---|---|---| +| GET | `/portfolio` | Riepilogo, curva del capitale, preset disponibili | +| PUT | `/portfolio/settings` | `{preset, auto_paper, auto_sell}` (admin) | +| POST | `/portfolio/reset` | `{bankroll, preset}`: cancella le scommesse simulate e ricomincia (admin) | +| GET | `/portfolio/bets` | Scommesse: `status` = `open`, `settled`, `excluded`, `all`; quelle aperte hanno il piano d'uscita (`plan`) | +| POST | `/portfolio/bets/{id}/exclude` · `/include` | Esclude o riammette una scommessa (admin) | +| POST | `/portfolio/bets/{id}/sell` | Vende subito una scommessa aperta sul book (admin) | +| GET · POST | `/portfolio/exclusions` | Esclusioni `{kind: market/event/category, value, label}` (POST admin) | +| DELETE | `/portfolio/exclusions/{id}` | Rimuove un'esclusione (admin) | + +### Backtest + +| Metodo | Path | Descrizione | +|---|---|---| +| GET · POST | `/backtest/runs` | Elenco dei backtest; avvio `{resolved_after, resolved_before, max_markets, min_volume, horizons, max_calls, exclude_decided, kinds}` (`kinds`: `binary` e/o `multi`) (POST admin, `409` se uno è già in corso) | +| GET | `/backtest/runs/{id}` | Avanzamento e riepilogo (metriche, calibrazione, suggerimenti) | +| GET | `/backtest/runs/{id}/cases` | Casi: `status` = `ok`, `skipped`, `all` | +| POST | `/backtest/runs/{id}/stop` · DELETE `/backtest/runs/{id}` | Ferma o elimina (admin) | +| GET · PUT | `/backtest/parameters` | `MODEL_WEIGHT_MAX`, `MIN_EDGE`, `JEV_CALIB_A`, `JEV_CALIB_B` in uso; PUT li sostituisce (admin) | +| POST | `/backtest/parameters/reset` | Torna ai valori del `.env` (admin) | + +### Uso e costi + +| Metodo | Path | Descrizione | +|---|---|---| +| GET | `/usage` | Oggi rispetto ai limiti, storico per giorno, servizio e funzione (`days`, max 90), limiti e prezzi in uso | +| PUT | `/usage/settings` | Limiti giornalieri e prezzi (admin) | +| POST | `/usage/settings/reset` | Torna ai valori del `.env` (admin) | + +### Mercati a più esiti + +| Metodo | Path | Descrizione | +|---|---|---| +| GET | `/multi/opportunities` | Eventi con un esito sottovalutato (`min_edge`, `min_evidence`, `include_hold`), con la valutazione economica | +| GET | `/multi` | Eventi con i primi esiti e l'ultima previsione. `q` (titolo o nome di un esito), `sort` = `volume`, `edge`, `signal`, `end_date`, `news` | +| GET | `/multi/{id}` | Tutti gli esiti, notizie collegate, storico delle previsioni | +| POST | `/multi/{id}/predict` | Previsione Jev della distribuzione (admin; `422` senza notizie) | + +### Allerte + +| Metodo | Path | Descrizione | +|---|---|---| +| GET | `/alerts` | Allerte con notizia, prezzi dopo l'allerta e movimento a favore. `kind` = `opportunities` (default) o `all` | +| GET | `/alerts/summary` | Risultati degli ultimi `days` giorni: movimento medio e quota a favore per ogni intervallo, esito dei mercati risolti | +| GET · PUT | `/alerts/settings` | Impostazioni (PUT admin) | +| POST | `/alerts/test-telegram` | Invia un messaggio di prova (admin) | +| POST | `/alerts/run` | Controlla subito le notizie nuove (admin) | + +Codici di errore di `/markets/{id}/predict`: `503` chiave TypeSafe mancante, `422` nessuna +notizia collegata, `409` mercato chiuso o senza prezzo, `404` mercato sconosciuto. + +Esempio di risposta di `/predictions/opportunities` (valori illustrativi): + +```json +[ + { + "market": { + "id": "123456", + "question": "Will the Fed cut interest rates in December 2026?", + "url": "https://polymarket.com/event/fed-decision-in-december", + "yes_price": 0.35, + "linked_articles": 4 + }, + "prediction": { + "model_probability": 0.8, + "evidence_strength": 0.75, + "blended_probability": 0.5188, + "edge": 0.1688, + "signal": "BUY_YES", + "kelly_fraction": 0.0649, + "article_count": 4 + } + } +] +``` diff --git a/docs/configurazione.md b/docs/it/configurazione.md similarity index 97% rename from docs/configurazione.md rename to docs/it/configurazione.md index fa46cf2..e2fdc93 100644 --- a/docs/configurazione.md +++ b/docs/it/configurazione.md @@ -1,6 +1,6 @@ # Configurazione -[← Torna al README](../README.md) · [Tutta la documentazione](README.md) +[← Torna al README](../../README.it.md) · [Tutta la documentazione](README.md) · [English](../configuration.md) Tutte le variabili del file `.env`. @@ -79,6 +79,7 @@ Tutte le variabili si impostano in `.env`. I valori segnaposto `your_...` contan | Variabile | Default | Descrizione | |---|---|---| +| `APP_LANGUAGE` | `en` | Lingua dei testi scritti senza una richiesta della dashboard: allerte Telegram, riassunti delle notizie, note salvate. `en` o `it`. La dashboard ha il suo pulsante IT/EN | | `INGEST_INTERVAL_MINUTES` | `60` | Intervallo della pipeline | | `ALERT_SCAN_MINUTES` | `10` | Controllo rapido per le allerte: fonti → collegamenti → allerte, senza riassunti (`0` lo disattiva) | | `ALERT_FOLLOWUP_MINUTES` | `5` | Ogni quanto registrare il prezzo dopo le allerte | diff --git a/docs/costi.md b/docs/it/costi.md similarity index 93% rename from docs/costi.md rename to docs/it/costi.md index 1a564c4..9333313 100644 --- a/docs/costi.md +++ b/docs/it/costi.md @@ -1,6 +1,6 @@ # Uso e costi delle API -[← Torna al README](../README.md) · [Tutta la documentazione](README.md) +[← Torna al README](../../README.it.md) · [Tutta la documentazione](README.md) · [English](../costs.md) Registro delle chiamate a pagamento, stima dei costi e limiti giornalieri. diff --git a/docs/it/deploy.md b/docs/it/deploy.md new file mode 100644 index 0000000..fa2ae7f --- /dev/null +++ b/docs/it/deploy.md @@ -0,0 +1,154 @@ +# Deploy + +[← Torna al README](../../README.it.md) · [Tutta la documentazione](README.md) · [English](../deploy.md) + +Immagini Docker per CPU e GPU, uso in locale e nella rete di casa, messa online su un VPS +con Cloudflare Tunnel. + +## Immagini Docker: CPU o GPU + +Ci sono due immagini: + +| File | Per | Note | +|---|---|---| +| `Dockerfile` (predefinito) | Server, VPS, macchine ARM, PC senza GPU NVIDIA | PyTorch solo CPU: immagine molto più piccola e build più veloce | +| `Dockerfile.cuda` | PC con GPU NVIDIA | PyTorch con CUDA; gli embedding vengono calcolati sulla GPU | + +Per usare la GPU (serve il driver NVIDIA e l'[NVIDIA Container Toolkit](https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/install-guide.html) sull'host, solo x86_64): + +```bash +docker compose -f docker-compose.yml -f docker-compose.gpu.yml up --build +``` + +Nel log all'avvio, `Embedding model ... on cuda:0` conferma che la GPU è in uso (`on cpu` +altrimenti). Le due immagini hanno nomi diversi (`news-aggregator-api:cpu` e `:cuda`), quindi +si può passare dall'una all'altra senza ricostruire ogni volta. Per un server la GPU non serve: +il modello è piccolo e gli articoli arrivano a gruppi. + +## Uso in locale e nella rete di casa + +Per usarla sul proprio computer non servono tunnel né altro: `tunnel` e `backup` sono +servizi opzionali, attivi solo se elencati in `COMPOSE_PROFILES`. Senza, il compose avvia +solo il database e l'app. + +```bash +cp .env.example .env # TYPESAFE_API_KEY, POSTGRES_PASSWORD e le altre chiavi +docker compose up -d --build +docker compose exec api python -m backend.auth.cli create-user tuonome --role admin +``` + +La dashboard è su **http://localhost:8000**. Il login funziona anche in HTTP: con +`SESSION_COOKIE_SECURE=auto` il cookie di sessione non chiede HTTPS quando l'indirizzo è +`localhost` (o `127.0.0.1`). Per il backup giornaliero anche in locale: +`COMPOSE_PROFILES=backup`. + +**Da un altro dispositivo di casa** (telefono, tablet, un altro PC), per esempio su +`http://192.168.1.10:8000`, servono due impostazioni in `.env`: + +| Variabile | Valore | Perché | +|---|---|---| +| `API_BIND` | `0.0.0.0` | Di base la porta 8000 risponde solo alla macchina su cui gira l'app | +| `SESSION_COOKIE_SECURE` | `false` | Con un indirizzo diverso da `localhost`, `auto` marca il cookie come `Secure`: in HTTP il browser non lo salva e l'accesso non riesce: la pagina di login lo segnala e indica questa impostazione | + +Poi `docker compose up -d` per applicarle. L'indirizzo del computer si trova con +`ip addr` (Linux), `ipconfig` (Windows) o nelle impostazioni di rete (macOS); se non +risponde, controlla che il firewall del computer lasci passare la porta 8000. + +> [!WARNING] +> Senza HTTPS password e sessione viaggiano in chiaro: va bene solo su una rete di casa +> fidata, mai su una rete pubblica. Per accedere da fuori usa +> [Cloudflare Tunnel](#deploy-su-un-vps-con-cloudflare-tunnel): funziona anche con l'app +> sul computer di casa, e in quel caso rimetti `SESSION_COOKIE_SECURE=auto` e +> `API_BIND=127.0.0.1`. + +## Deploy su un VPS con Cloudflare Tunnel + +Il modo più economico per tenerla online: un piccolo VPS (per esempio OVH VPS-1 o Hetzner: +2 vCore, 4 GB di RAM bastano) con Docker, e **Cloudflare Tunnel** per l'HTTPS. Il tunnel +esce dal server verso Cloudflare: nessuna porta da aprire, nessun certificato da gestire, +l'indirizzo IP del server resta nascosto. Serve un dominio gestito da Cloudflare (piano +gratuito); il tunnel è gratuito. + +```mermaid +flowchart LR + U[Browser] -- HTTPS --> CF[Cloudflare] + CF -- tunnel in uscita --> T[cloudflared] + subgraph VPS + T --> A[api :8000] + A --> D[(Postgres)] + B[backup giornaliero] --> D + end +``` + +**1. Il server.** Ubuntu 24.04, accesso SSH con chiave. Firewall con la sola porta SSH aperta: + +```bash +sudo apt update && sudo apt upgrade -y +sudo ufw allow OpenSSH && sudo ufw enable +``` + +La porta 8000 dell'app ascolta solo su `127.0.0.1` (`API_BIND`): Docker aggira `ufw` per le +porte pubblicate, per questo non viene pubblicata sulla rete. + +**2. Docker e il codice.** + +```bash +curl -fsSL https://get.docker.com | sh +sudo usermod -aG docker $USER # poi esci e rientra +git clone https://github.com/botta0oss/News_Aggregator.git && cd News_Aggregator +cp .env.example .env +``` + +**3. Il tunnel.** Nella dashboard di Cloudflare: *Zero Trust → Networks → Tunnels → Create a +tunnel → Cloudflared*, dagli un nome e copia il **token** (la lunga stringa dopo `--token` nel +comando di installazione proposto: non serve eseguirlo, `cloudflared` gira già nel compose). +Poi aggiungi un *Public hostname*: sottodominio a scelta (es. `news.tuodominio.it`), tipo +**HTTP**, URL **`api:8000`**. + +**4. Il file `.env`.** Oltre alle chiavi delle API: + +| Variabile | Valore | +|---|---| +| `POSTGRES_PASSWORD` | Una password casuale, prima del primo avvio: `openssl rand -hex 24` | +| `COMPOSE_PROFILES` | `tunnel,backup` | +| `CLOUDFLARE_TUNNEL_TOKEN` | Il token del passo 3 | +| `CLIENT_IP_HEADER` | `CF-Connecting-IP` (IP reale dei visitatori per i limiti di accesso) | +| `PUBLIC_URL` | `https://news.tuodominio.it` (link nelle notifiche Telegram) | +| `API_DOCS_ENABLED` | `false` | +| `ADMIN_USERNAME`, `ADMIN_PASSWORD` | Il primo amministratore; dopo il primo avvio togli la password dal file | +| `DAILY_JEV_CALL_LIMIT`, `DAILY_AI_BUDGET_USD` | Un tetto alla spesa per le chiamate a pagamento | + +**5. Avvio.** + +```bash +docker compose up -d --build # la prima build scarica il modello: qualche minuto +docker compose logs -f api tunnel # attendi "Application startup complete" e "Registered tunnel connection" +``` + +La dashboard è su `https://news.tuodominio.it`. Per un livello di protezione in più, *Zero Trust +→ Access* può chiedere un codice via email prima ancora della pagina di login (gratuito fino a +50 utenti). + +**Aggiornare** all'ultima versione (dati e backup restano): + +```bash +./scripts/update.sh +``` + +**Backup.** Con il profilo `backup` ogni giorno viene salvato un dump in `./backups`, tenuto per +`BACKUP_KEEP_DAYS` giorni. È sullo stesso disco del database: copialo anche fuori dal server +(per esempio con `rclone` su Cloudflare R2, o scaricandolo con `scp`), oppure attiva i backup +del VPS offerti dal provider. Per ripristinarne uno: + +```bash +docker compose stop api +docker compose exec -T db pg_restore -U postgres -d postgres --clean --if-exists < backups/newsagg-AAAAMMGG-HHMM.dump +docker compose start api +``` + +**Se qualcosa non va.** +- *Errore 502 o 1033 da Cloudflare*: l'app non risponde ancora (al primo avvio carica il + modello) o il tunnel non è connesso: `docker compose logs api tunnel`. +- *Il tunnel non si connette*: token sbagliato, o nel *Public hostname* l'URL non è `api:8000`. +- *Password del database cambiata dopo il primo avvio*: il database tiene quella vecchia. + Aggiornala anche lì: `docker compose exec db psql -U postgres -c "ALTER USER postgres PASSWORD 'nuova'"`. diff --git a/docs/guida.md b/docs/it/guida.md similarity index 91% rename from docs/guida.md rename to docs/it/guida.md index de00b31..21c39e8 100644 --- a/docs/guida.md +++ b/docs/it/guida.md @@ -1,6 +1,6 @@ # Guida alla dashboard -[← Torna al README](../README.md) · [Tutta la documentazione](README.md) +[← Torna al README](../../README.it.md) · [Tutta la documentazione](README.md) · [English](../guide.md) Le pagine della dashboard, il flusso di lavoro consigliato e cosa fare se non si apre. @@ -19,6 +19,12 @@ Impostazioni, Uso e costi, «Come funziona» e account stanno in fondo. tutte le altre. - Il numero su «Allerte» indica le opportunità delle ultime 24 ore. +**Lingua.** Il pulsante **IT / EN** in alto a destra, accanto a quello del tema, passa tutta la +dashboard dall'italiano all'inglese. La scelta viene ricordata nel browser; alla prima visita +la dashboard segue la lingua del browser. Numeri, date e importi seguono la lingua +(`1,5 $` / `$1.5`), e così i testi che il server scrive per la dashboard (piani, motivi, +messaggi d'errore). + | Sezione | Cosa mostra | |---|---| | **Opportunità** | Mercati con segnale attivo ordinati per edge: prezzo, stima Jev e probabilità blended sulla stessa scala 0–100 %, puntata suggerita e forza delle evidenze. Filtri per edge ed evidenze minime. | @@ -36,8 +42,8 @@ Impostazioni, Uso e costi, «Come funziona» e account stanno in fondo.

- - La scheda Cosa fare nel dettaglio di un mercato + + La scheda Cosa fare nel dettaglio di un mercato

@@ -69,7 +75,7 @@ Scelte di design: - **Palette verificata** per il daltonismo su entrambi i temi. - **Forme distinte:** cerchio e rombo, linea continua e tratteggiata. Ogni grafico ha legenda con valori e tabella alternativa. - **Numeri in carattere monospazio** (Fira Code) e prezzi in centesimi, come su Polymarket. -- **Accessibilità:** navigabile da tastiera, target touch di 44 px, rispetta la riduzione del movimento, nessuno scroll orizzontale da 375 px in su. +- **Accessibilità:** navigabile da tastiera, target touch di 44 px, rispetta la riduzione del movimento, nessuno scroll orizzontale da 320 px in su. ## Flusso di lavoro consigliato diff --git a/docs/metodo.md b/docs/it/metodo.md similarity index 99% rename from docs/metodo.md rename to docs/it/metodo.md index c07aeea..099d0b7 100644 --- a/docs/metodo.md +++ b/docs/it/metodo.md @@ -1,6 +1,6 @@ # Il metodo: dalle notizie alla previsione -[← Torna al README](../README.md) · [Tutta la documentazione](README.md) +[← Torna al README](../../README.it.md) · [Tutta la documentazione](README.md) · [English](../method.md) Come le notizie vengono raccolte, collegate ai mercati e trasformate in una stima di probabilità. diff --git a/docs/sicurezza.md b/docs/it/sicurezza.md similarity index 96% rename from docs/sicurezza.md rename to docs/it/sicurezza.md index 94f8cc9..1124b7f 100644 --- a/docs/sicurezza.md +++ b/docs/it/sicurezza.md @@ -1,6 +1,6 @@ # Accesso e sicurezza -[← Torna al README](../README.md) · [Tutta la documentazione](README.md) +[← Torna al README](../../README.it.md) · [Tutta la documentazione](README.md) · [English](../security.md) Utenti e ruoli, sessioni, protezioni e messa online. diff --git a/docs/strategia.md b/docs/it/strategia.md similarity index 98% rename from docs/strategia.md rename to docs/it/strategia.md index 126d776..7368d6b 100644 --- a/docs/strategia.md +++ b/docs/it/strategia.md @@ -1,6 +1,6 @@ # Valutazione economica, strategia e portafoglio -[← Torna al README](../README.md) · [Tutta la documentazione](README.md) +[← Torna al README](../../README.it.md) · [Tutta la documentazione](README.md) · [English](../strategy.md) Se una previsione conviene davvero, cosa fare (comprare, aspettare, vendere) e come il portafoglio simulato misura i risultati. diff --git a/docs/sviluppo.md b/docs/it/sviluppo.md similarity index 92% rename from docs/sviluppo.md rename to docs/it/sviluppo.md index 5b1489d..036332d 100644 --- a/docs/sviluppo.md +++ b/docs/it/sviluppo.md @@ -1,6 +1,6 @@ # Sviluppo e test -[← Torna al README](../README.md) · [Tutta la documentazione](README.md) +[← Torna al README](../../README.it.md) · [Tutta la documentazione](README.md) · [English](../development.md) Test, CI e struttura del codice. @@ -50,6 +50,7 @@ In CI un database non raggiungibile fa fallire i test invece di saltarli. | `tests/test_backtest.py` | Pianificazione, notizie senza senno di poi (anche d'archivio), metriche, bootstrap, parametri verificati sui mercati recenti | | `tests/test_bulk_predict.py` | «Valuta tutti con Jev»: flusso completo, attesa sui limiti di frequenza, stop dopo errori ripetuti | | `tests/test_usage.py` | Registro delle chiamate, costi stimati, limiti giornalieri e avvisi | +| `tests/test_i18n.py` | Testi in inglese e italiano: lingua della richiesta (`X-Lang`), `APP_LANGUAGE`, piani in inglese, ogni testo della dashboard tradotto | ## Struttura del progetto @@ -57,6 +58,7 @@ In CI un database non raggiungibile fa fallire i test invece di saltarli. backend/ ├── main.py # app FastAPI, avvio e chiusura ├── config.py # impostazioni da .env +├── i18n.py # lingua dei testi (X-Lang, APP_LANGUAGE), tr(it, en) ├── overrides.py # parametri cambiati dalla dashboard (es. dopo un backtest) ├── auth/ # password, sessioni, ruoli, limite tentativi, CLI utenti ├── ai/ @@ -99,8 +101,8 @@ backend/ ├── db/ # modelli SQLAlchemy, query e migrazioni idempotenti └── api/ # schemi e route FastAPI frontend/ # dashboard senza build: app.js, ui.js, charts.js, explain.js, - # economics.js, strategy.js, views/ (una per pagina) -docs/ # documentazione e screenshot + # economics.js, strategy.js, i18n.js + i18n-en.js, views/ (una per pagina) +docs/ # documentazione (inglese; italiano in docs/it) e screenshot scripts/ ├── check_env.py # verifica chiavi e database └── update.sh # aggiornamento sul server diff --git a/docs/verifica.md b/docs/it/verifica.md similarity index 98% rename from docs/verifica.md rename to docs/it/verifica.md index 081acdb..adb365d 100644 --- a/docs/verifica.md +++ b/docs/it/verifica.md @@ -1,6 +1,6 @@ # Verificare i risultati: backtest e calibrazione -[← Torna al README](../README.md) · [Tutta la documentazione](README.md) +[← Torna al README](../../README.it.md) · [Tutta la documentazione](README.md) · [English](../verification.md) Quanto fidarsi delle previsioni, sul passato e sui mercati che si risolvono. diff --git a/docs/method.md b/docs/method.md new file mode 100644 index 0000000..57686bd --- /dev/null +++ b/docs/method.md @@ -0,0 +1,180 @@ +# The method: from news to forecast + +[← Back to the README](../README.md) · [All documentation](README.md) · [Italiano](it/metodo.md) + +How news is collected, linked to markets and turned into a probability estimate. + +## The pipeline, step by step + +| Step | Description | +|---|---| +| **Collection** | Reads the active sources every `INGEST_INTERVAL_MINUTES` (and right away at startup). Sources are managed from the dashboard; `feeds.yaml` is the catalogue of recommended sources. | +| **Deduplication** | L1: hash of the normalised URL. L2: near-identical title, or very similar title and text in the last 48 hours: the same story rewritten by several outlets ends up in a single group, with the number of different outlets reporting it. | +| **Summary** | Gemini → Groq → Ollama, falling back to an excerpt of the text. Written in `APP_LANGUAGE`. | +| **Classification** | Jev assigns category (8, aligned with Polymarket's topics), region, opinion or news, relevance to markets, clickbait, authority, depth and urgency. Without a key it uses weighted keywords. | +| **Search** | Full-text search on title, text and summary, with highlighting and filters. | +| **Markets** | Syncs, read only, Polymarket's most traded Yes/No markets and multi-outcome events. | +| **Targeted search** | For the most traded markets it searches Google News for news with the key terms of the question, even on topics the usual sources do not cover. | +| **Linking** | Associates each market with the recent news about the same subject: semantic similarity (pgvector) plus key terms (names, acronyms, numbers, synonyms). | +| **Evidence selection** | Jev reads the most useful news: relevant, from reliable sources, recent, one per story with the number of sources confirming it. | +| **Forecast** | Jev estimates the probability of YES from the market rules and the news, without seeing the price; the estimate is calibrated on markets already resolved. | +| **Signal** | Pools the estimate with the price (in log-odds, with a weight that grows with the strength of the news) and compares them: `BUY_YES`, `BUY_NO` or `HOLD`. | +| **Economic assessment** | Decides whether it is really worth it and how much to stake: real price from the book, fees, uncertainty of the estimate, annualised return, Kelly on the book and risk limits. | +| **Strategy** | For each market it says what to do (buy YES/NO, wait, avoid, hold, sell), with which limit orders, at what prices the decision would change, and why or why not. | +| **Simulated portfolio** | Every bet that is worth it becomes a virtual bet, sold when the plan says so or closed at resolution, to measure the results before using real money. | +| **Alerts** | When fresh, relevant news comes out for a market, Jev assesses it right away. If it is worth it a Telegram notification arrives; then the price after 15 minutes, 1, 6 and 24 hours is recorded to measure whether the alert got ahead of the market. | + +## How news is linked to markets + +1. **Candidates.** pgvector finds the recent news similar to the market question. It compares + both the title and the title plus the start of the text, because many titles are vague. +2. **Key terms.** Names and acronyms (weight 2), distinctive words and numbers (weight 1), and + years, months and days (weight 0.5) are extracted from the question. The most common + synonyms count too: Fed = Federal Reserve = Powell, BTC = Bitcoin, 100k = 100,000 = + $100,000. Short acronyms are compared case-sensitively, so «US» does not match «us». +3. **Match.** It computes `0.7 × similarity + 0.3 × terms found`. If the news does not mention + any name in the question the match drops to 60 %: it is the same topic about another + subject, for example news about the ECB for a market on the Fed. News with a match + ≥ `MARKET_MATCH_THRESHOLD` is kept. +4. **Usefulness for Jev.** The usefulness of each news item is `match × source reliability × + freshness × Jev's judgement`. + - Reliability depends on authority, clickbait and opinion pieces. The authority of a single + article is a noisy estimate: for outlets with at least 5 classified articles, half of it + is the outlet's average. + - Freshness halves every `EVIDENCE_HALF_LIFE_HOURS`. + - News that Jev has already judged not relevant for that market is removed. + + A **story** groups articles with a near-identical title (`SIMILARITY_THRESHOLD`) or with a + very similar title + text in the last `STORY_WINDOW_HOURS` hours + (`STORY_SIMILARITY_THRESHOLD`: outlets rewrite titles). Confirmations count different + outlets, not articles: a source repeating the news does not make it more certain. + Jev reads the `MARKET_MAX_ARTICLES` most useful: one per story, at most 3 per source. For each + news item it gets the age, the type (news or opinion), the source reliability and the number + of outlets that reported it, plus the days left before the market's end date. +5. **Targeted search.** On every run, for the `TARGETED_NEWS_MAX_MARKETS` most traded markets + not searched in the last `TARGETED_NEWS_REFRESH_HOURS` hours, the key terms are searched on + Google News. Results are saved as news of the automatic «Targeted search» source, with the + name of the original outlet, and follow the same path: deduplication, classification, + linking. It can also be run by hand from a market's detail page («Search news»). If the + service does not answer for 3 markets in a row, the search stops until the next run. + +In a market's detail page each news item shows the match, the key terms found, the number of +sources confirming it and whether it comes from the targeted search. + +## How a forecast is made + +### 1. Questions to Jev + +Each market gets **a single** `system_one` call. The state contains the market question, the +resolution rules, the end date, today's date and the linked news. **The market price is not +passed**, so Jev's estimate stays independent and comparable with the price. + +| Question | Primitive | Use | +|---|---|---| +| `resolves_yes` | `Noul` | Probability that the market resolves YES | +| `evidence_strength` | `Score` (0–4) | How much the news really informs the outcome | +| `relevant_nX` | `Noul` | Is news item X relevant to the outcome? | +| `impact_nX` | `Choice` | News item X raises, lowers or does not change the probability of YES | + +### 2. From estimate to signal + +Liquid markets are usually already well calibrated, so Jev's estimate is not used as it is: + +1. **Calibration (Platt scaling).** `logit P_cal = JEV_CALIB_A + JEV_CALIB_B × logit P_jev`, + with `logit p = ln(p / (1 − p))`. The two numbers are estimated by the backtest on + resolved markets: `B < 1` softens an overconfident Jev, `B > 1` sharpens one that is too + cautious. By default (0 and 1) the estimate stays as it is. +2. **Pooling with the price in log-odds**, with a weight that grows with the evidence strength: + +``` +w = MODEL_WEIGHT_MAX × evidence_strength +logit(blended) = w × logit(P_cal) + (1 − w) × logit(price) +edge = blended − price +``` + +Averaging in log-odds is the standard way to combine calibrated forecasts: a simple average +(`BLEND_METHOD=linear`, the earlier method) makes them systematically too timid. With +`w = 0` the blend is the price, with `w = 1` it is Jev. + +With `JEV_SAMPLES > 1` each forecast asks Jev several times and averages the answers (in +log-odds): less noise, but every call is paid. + +- `edge ≥ MIN_EDGE` and evidence ≥ `MIN_EVIDENCE` → **BUY_YES** +- `edge ≤ −MIN_EDGE` and evidence ≥ `MIN_EVIDENCE` → **BUY_NO** +- otherwise → **HOLD** + +The suggested stake is fractional Kelly: for YES `(blended − price) / (1 − price) × KELLY_FRACTION`, +for NO the symmetric formula on the NO price. + +### Example + +| | Value | +|---|---| +| Market price (YES) | 0.35 | +| Jev estimate | 0.80 | +| Evidence strength | 3/4 → 0.75 | +| Weight `w` | 0.5 × 0.75 = 0.375 | +| Blended probability | logit⁻¹(0.375 × logit 0.80 + 0.625 × logit 0.35) ≈ **0.533** | +| Edge | **+0.183** → `BUY_YES` | +| Stake (simple Kelly) | (0.533 − 0.35) / 0.65 × 0.25 ≈ **7.0 % of bankroll** | + +The actual stake is then decided by the [economic assessment](strategy.md#economic-assessment-and-simulated-portfolio), which accounts for the real price, costs, uncertainty, time and limits. + +## Multi-outcome markets + +Many of the most traded events on Polymarket have several possible answers, only one of which +wins: «Who will win the election?», «Who will win the Champions League?». On Polymarket each +outcome is a separate YES/NO share; here they are kept together and separate from the Yes/No +markets, in the **Multi-outcome** section, because they read as a distribution. + +- **Sync.** The most traded open events come in (Gamma `/events`, `negRisk`, at least 3 + outcomes; `MULTI_SYNC_LIMIT`). The outcomes of these events no longer show up among the + Yes/No markets, the opportunities and the alerts. The winner is detected when the event + closes. +- **News.** Linking works as for Yes/No markets, on the event title. News naming one of the + outcomes (a candidate, a team) gets a bonus. +- **Forecast.** With a single call Jev gives the probability of each outcome (a multiple-choice + question), without seeing the prices. + - It gets the `MULTI_MAX_OUTCOMES` most likely outcomes (12 by default); the others are + summed into «Other outcomes». + - Prices are normalised to 100 %, so the market's margin disappears from the reference. + - The blend combines the two distributions log-linearly (`p ∝ Jev^w × market^(1−w)`, then + normalised), with the same weight as Yes/No markets. + - The edge is measured on the **real price** of the YES share, the one you pay: normalising + removes the margin from the reference, not from the cost. + - The signal points to the outcome furthest from its price, if the edge exceeds `MIN_EDGE` + and the evidence `MIN_EVIDENCE`: **buy YES** if it is undervalued, **buy NO** if it is + overvalued (often a favourite the market is too optimistic about). +- **Arbitrage.** Only one outcome wins, so a YES on every outcome always pays $1 and a NO on + every outcome always pays $N − 1. If at the best book price buying the whole set costs less, + fees included, the event shows the «Arbitrage» badge with the gain per set. Only the first + level of the book is known: the quantity can be small and the price change quickly. +- **View.** In the list, for each event, the top outcomes with the price bar and the markers + of Jev (diamond) and blend (circle). In the detail page: all outcomes, the table, the news + (with the outcome each one favours) and the history. + +**Economics, portfolio, alerts and backtest.** Everything that holds for Yes/No markets holds +for outcomes: +- **Economic assessment.** After each forecast the 3 outcomes furthest from their price are + assessed, on the YES share if undervalued and on the NO share if overvalued: real book price, + fees, uncertainty, annualised return, Kelly and the preset limits. In the event's detail page + the «Worth it?» card lets you choose the outcome. +- **Simulated portfolio.** The best outcome becomes a simulated bet, if it is worth it. The + per-event limit applies to all outcomes together, so you do not bet on three candidates of + the same election beyond the preset's risk. Exclusions per event and category and closing at + resolution work as for Yes/No markets. +- **Opportunities.** Events with an under- or overvalued outcome show up in a separate block, + below the Yes/No markets. +- **Alerts.** Fresh, relevant news linked to an event makes the distribution be recomputed + right away (one call). If it is worth it a «Buy YES on …» (or «Buy NO on …») notification + arrives and the following price is measured as for Yes/No markets. +- **Backtest.** Choose «Multi-outcome» among the market types. For each resolved event the + historical prices are rebuilt and the outcomes shown to Jev are the most likely according to + the price of the time. The result reports: + - the multi-outcome Brier (0 = perfect, 2 = certain and wrong); + - the probability given to the winner; + - how often the favourite won, for Jev and for the market; + - the simulated bets. + +Technically each outcome is also a row of the markets table, marked with `multi_event_id` and +hidden from the Yes/No lists: book, economic assessment, bets and closing use the same code. diff --git a/docs/security.md b/docs/security.md new file mode 100644 index 0000000..ad2753a --- /dev/null +++ b/docs/security.md @@ -0,0 +1,64 @@ +# Access and security + +[← Back to the README](../README.md) · [All documentation](README.md) · [Italiano](it/sicurezza.md) + +Users and roles, sessions, protections and going online. + +The whole API, except sign-in, requires an authenticated user. The dashboard's static files +are public but contain no data. + +**Roles** + +| Role | Can do | +|---|---| +| `admin` | Everything: view the data, update news and markets, ask Jev for forecasts (paid) | +| `viewer` | View news, markets, forecasts and calibration | + +**User management** (there is no public sign-up): + +```bash +python -m backend.auth.cli create-user alice --role admin # asks for the password +python -m backend.auth.cli create-user bob # viewer +python -m backend.auth.cli list-users +python -m backend.auth.cli set-password alice # also closes all her sessions +python -m backend.auth.cli disable bob # disables and signs out +python -m backend.auth.cli enable bob +python -m backend.auth.cli revoke-sessions alice +``` + +The command-line messages follow `APP_LANGUAGE`. + +Alternatively, at the first start with no users an admin is created from `ADMIN_USERNAME` and +`ADMIN_PASSWORD`. After the first start remove `ADMIN_PASSWORD` from the `.env` file. + +**How it works** + +- **Passwords:** Argon2id hash, at least 12 characters, they cannot contain the username. + Hashes with old parameters are updated at the next sign-in. +- **Server-side sessions:** the `nm_session` cookie contains a random 256-bit token; the + database only holds its SHA-256 hash. The cookie is `HttpOnly`, `SameSite=Lax` and `Secure` + (except on `localhost` over HTTP). +- **Expiry:** each session lasts at most `SESSION_TTL_HOURS` and ends after + `SESSION_IDLE_MINUTES` of inactivity. Sign-out, password change and disabling the user + revoke it right away. +- **CSRF:** every request that changes data must send the session's token in the + `X-CSRF-Token` header. The dashboard does it by itself. +- **Sign-in attempts:** after `LOGIN_MAX_ATTEMPTS` failures per IP and username, or + `LOGIN_MAX_ATTEMPTS_PER_IP` per IP, sign-in is blocked for `LOGIN_WINDOW_MINUTES`. The + answer is the same whether the user exists or not, and takes the same time. +- **Security headers:** Content-Security-Policy without inline scripts, `X-Frame-Options: + DENY`, `nosniff`, HSTS when the request arrives over HTTPS. +- **CORS:** off. The dashboard is on the same origin; any external origins must be listed in + `CORS_ORIGINS` (the `*` wildcard is ignored). + +**Going online** + +1. Put the app behind HTTPS: the simplest way is Cloudflare Tunnel, ready in the compose file + (see [Deploy on a VPS](deploy.md#deploy-on-a-vps-with-cloudflare-tunnel), with + `CLIENT_IP_HEADER=CF-Connecting-IP`); alternatively a reverse proxy (Caddy, nginx, Traefik). +2. With a reverse proxy start uvicorn with `--proxy-headers` (already in the Dockerfile), so + the attempt limit sees the client's real IP. If the proxy does not run on the same machine, + add `--forwarded-allow-ips` with its address. +3. Set `API_DOCS_ENABLED=false` so as not to publish the API schema. +4. The attempt limit is in memory: with several workers each one has its own counters. With + several workers add a limit on the proxy too. diff --git a/docs/strategy.md b/docs/strategy.md new file mode 100644 index 0000000..5264476 --- /dev/null +++ b/docs/strategy.md @@ -0,0 +1,106 @@ +# Economic assessment, strategy and portfolio + +[← Back to the README](../README.md) · [All documentation](README.md) · [Italiano](it/strategia.md) + +Whether a forecast is really worth it, what to do (buy, wait, sell) and how the simulated portfolio measures the results. + +## Economic assessment and simulated portfolio + +An edge on paper is not enough: the economic assessment (`backend/betting/`) decides whether a +forecast is really worth it, how much to stake and at what maximum price. It is computed after +every forecast and, live, in the **Worth it?** card of the market detail page. + +1. **Real price.** It reads the book of the side to buy from Polymarket's CLOB (public API, + read only) and computes the average price you would pay for that amount. + Fee (only for those who take from the book, as here): `rate × price × (1 − price)` per + share, highest at 50¢ and zero at the extremes. The rate depends on the category (World 0, + Politics/Technology/Economy 4%, Sport 3%, Culture/Science 5%, Crypto 7%; unknown category: + `DEFAULT_FEE_BPS`). The CLOB `/fee-rate` endpoint says whether the market has fees on: if it + answers 0 the fee is zero. Without a book it estimates a 4-level book, from the mid price + + half the spread upwards, one spread apart (40/30/20/10% of half the declared liquidity): + buying a lot costs progressively more. It always says so. +2. **Prudent probability.** `p_prudent = p_blended − z × σ`, where + `σ = w × √(p_jev (1 − p_jev) / (MODEL_PSEUDO_COUNT × evidence + 1))`. After 30 resolved + markets σ is multiplied by `√(observed Brier / expected Brier)`, where the expected Brier is + the one perfectly calibrated forecasts would have (the average of `p (1 − p)`): widened if + the blended forecasts were overconfident, narrowed if they were cautious (a factor between + 0.75 and 2). +3. **Net margin.** `p_prudent − (price + fee)` must exceed the preset's threshold. +4. **Time.** The prudent expected return is annualised over the days left before the end date + and must exceed `RISK_FREE_RATE` + the preset's premium. +5. **How much to stake.** Kelly capital is computed on the book, because buying more worsens + the price. A fraction of it is taken (depending on the preset) and then the limits per + market, event, category, total invested, available cash and share of the book apply. The + **maximum price** to pay is the price at which `price + fee` leaves exactly the minimum + margin: `price + fee(price) = p_prudent − minimum margin`. +6. **Verdict.** *Worth it*, *Barely worth it* (the stake was cut to less than half by the + limits) or *Not worth it*, always with the reasons. + +| Preset | Kelly | z | Net margin | Annual premium | Max per market | Max per event | Max per category | Max invested | Min liquidity | Max end date | +|---|---|---|---|---|---|---|---|---|---|---| +| Prudent | ×0.15 | 1.64 | 4 pts | 15% | 2% | 5% | 15% | 40% | $25,000 | 120 d | +| Balanced | ×0.25 | 1.0 | 3 pts | 8% | 4% | 8% | 25% | 60% | $10,000 | 365 d | +| Aggressive | ×0.5 | 0.5 | 2 pts | 3% | 8% | 15% | 40% | 85% | $5,000 | 730 d | + +**Simulated portfolio** (*Portfolio* page): +- **Automatic bets:** every forecast with a *Worth it* or *Barely worth it* verdict becomes a virtual bet at the real price of the moment, at most one open per market. +- **Selling:** after every market update and every new forecast, open positions are reviewed + with the [strategy](#buy-and-sell-strategy): if the plan says «Sell», the shares are sold on + the book (status «sold», with price and reason). It can be turned off («Sell automatically») + or done by hand. Sales at a profit count as won. +- **Closing:** happens when the market resolves for good (price at 1/0 and, if Gamma reports + it, `umaResolutionStatus` = «resolved»: an outcome that is only proposed or disputed can still + change). A winning share is worth $1; if the market closes 50-50 each share is worth $0.50 + and the bet counts as «voided» (outside the win rate). +- **What it shows:** current value, realised and unrealised profits, win rate, capital curve and a comparison between expected and actual profit. +- **Exclusions:** + - single bets, even already closed ones (they do not count in the results and can be readmitted); + - markets, events or categories, which automatic bets skip. +- **Settings:** you can choose the preset, turn automatic bets and sales on or off, or start over with a new capital. + +## Buy and sell strategy + +The **What to do** card of a market's detail page (and of each outcome of multi-outcome +markets) turns forecast and economic assessment into instructions (`backend/betting/strategy.py`): + +| Action | When | What it says | +|---|---|---| +| **Buy YES / NO** | The assessment says *Worth it* | Limit order: shares, maximum price, amount. Once bought, a limit sell order | +| **Wait** | The edge is there, but at the current price costs and uncertainty eat it up | The price at which it pays: a pending order | +| **Avoid** | The problem is not the price: illiquid market, end date beyond the preset, risk limits full | The reasons | +| **No action** | The estimate is close to the price | At what price buying YES or NO would become worth it | +| **Hold** | There is a position and the price is still below the estimate | The limit sell order | +| **Sell** | The price has reached the estimate, or a new forecast has turned it | How many shares and at what price | + +**Prices at which the decision changes.** For each YES price between 1¢ and 99¢ the blended +estimate is recomputed as in the forecast (calibrated Jev pooled with that price) and the +checks are run again: signal (`MIN_EDGE`), margin after fees and uncertainty, annual return. +The highest price that passes them all is «buy YES below»; the lowest for NO is «buy NO +above». The detail page shows them on a price scale together with the current price. + +**When to sell.** Holding a share is worth the probability that it wins, discounted for the +time the capital stays locked at the return the preset asks for (risk-free rate + premium). +Selling is worth the bid on offer minus the fee. The selling price is the lowest at which +selling pays at least as much as holding: + +``` +sell if bid − fee(bid) ≥ P(side wins | price = bid) / (1 + required return)^(days/365) +``` + +With little time to the end date it almost matches the estimate; with many months it is lower, +because cashing in earlier frees the capital. There is no fixed stop-loss: if the price falls +but the estimate does not, the share is even better value. You sell instead when a new +forecast says the opposite side is worth it. + +**Why yes / why not.** Each plan lists the reasons: the difference between estimate and price, +the news Jev judged for or against (with the titles), the margin after costs, the annual +return, the time, the probability of losing, the estimated book, the exclusions and the past +results (edge over the price on resolved markets and closing price of the signals). + +**Confidence** *high*, *medium* or *low*: it goes up with strong news and good past results; +it goes down with weak news, an uncertain estimate, estimated prices without a book and +missing or negative past results. + +The plan also shows up in the opportunities (with the prices at the time of the forecast), in +the portfolio (exit plan of each open position) and in the Telegram notifications (buy and +sell orders). diff --git a/docs/verification.md b/docs/verification.md new file mode 100644 index 0000000..de9f0ab --- /dev/null +++ b/docs/verification.md @@ -0,0 +1,106 @@ +# Checking the results: backtest and calibration + +[← Back to the README](../README.md) · [All documentation](README.md) · [Italiano](it/verifica.md) + +How much to trust the forecasts, on the past and on the markets as they resolve. + +## Backtest + +The simulated portfolio and the alerts measure the results as markets resolve, that is over +weeks or months. The backtest gives a first answer right away: how would Jev have forecast the +markets already resolved? + +**How it works.** +1. **Markets.** It takes the Yes/No markets closed in the chosen period, most traded first + (Gamma API, `closed=true`). +2. **Moments.** For each market and each horizon (1, 7 or 30 days before the close) it rebuilds + the situation at that moment: + - the **price of the time**, from the CLOB history (`/prices-history`); + - the **news of the 7 previous days**, from one of two sources (chosen in the form): + - **archive**: the articles this app had already saved at that date + (`fetched_at ≤ moment`). No later news can get in: it is the honest measure, but it only + covers the period in which the app was running; + - **Google News** with the `after:`/`before:` filters: it covers any period, but the date + filters let through pages updated later, and results can look better than they are. + Those with a later date are discarded anyway. + + By default the archive is used when it has news for that case, otherwise Google News. Each + case records the source used, and the results of the archive-only cases are shown + separately. +3. **News selection.** Match (meaning + key terms) and choice of the best, as in the app. +4. **Forecast.** Jev gets the same request, with «today» set to that date and without seeing + the price. +5. **Signal and bet.** Blend, signal and economic assessment with the portfolio's preset. The + historical book does not exist: the price is the one of the time plus half the typical + spread. +6. **Comparison.** The actual outcome of the market says who was right. + +For multi-outcome events, the outcomes shown to Jev are the most likely **according to the +price of the time** (up to `MULTI_MAX_OUTCOMES`, among the 30 most traded), as live. If the +winner was priced low it ends up among the «other outcomes»: choosing them knowing who won +would make the results look better than they are. + +**Skipped cases** (without using calls): +- historical price not available; +- outcome already priced in (below 3 % or above 97 %, can be turned off); +- no news in those days. + +There is a Jev call limit for each backtest. The work runs in the background, with progress +and a button to stop it. If the server restarts, the backtest shows as stopped. + +**Results.** +- Brier score (lower is better) of price, Jev and blend, in total, by horizon and by category. +- **Edge over the price** (price Brier − blended Brier) with the **95 % interval**, computed + with a bootstrap that resamples whole markets: the horizons of the same market share the + outcome and are not independent cases. If the interval includes zero, the edge may be due to + chance. +- Share of right signals and simulated bets: profit and ROI. +- Calibration chart (forecast against happened). +- Table of cases, with the news Jev read. + +**Suggested parameters.** +- **Jev's calibration** (Platt scaling: logistic regression of the outcome on `logit P_jev`, + with a slight penalty towards «no correction»). +- With Jev calibrated, **Jev's maximum weight** that gives the blend the lowest Brier. +- With that weight, the **minimum edge** that would have paid most staking $1 per signal, if + there are at least 10 bets. +- **Out-of-sample check.** Markets are sorted by close date: parameters are estimated on the + oldest 70 % and tried on the most recent 30 %, as they would be used live. A value is + *recommended* only if it improves there too; the values shown are then re-estimated on all + markets. At least 10 markets per part are needed, otherwise nothing is recommended. +- Reliability depends on the **distinct markets** (not the cases): *low* below 30, *medium* + below 100, *high* from 100 up. + +An admin can apply the recommended values with one click: they are saved in the database, +apply to the following forecasts and override the `.env` values until you press «Restore the +.env values». Only `MODEL_WEIGHT_MAX`, `MIN_EDGE`, `JEV_CALIB_A` and `JEV_CALIB_B` can be +changed. + +**The limit.** Jev might already know the outcome of past events: markets resolved before the +date up to which its model's knowledge goes can give results that are too good. For an honest +measure choose markets closed after that date and, when the archive covers them, archive news +only. + +## Calibration + +When a followed market resolves, the sync detects it and saves the outcome. +`/predictions/calibration` compares, on the latest forecast made for each market, the +**Brier score** (mean squared error, lower is better) of: + +- `brier_market`: the market price at the time of the forecast; +- `brier_model`: Jev's raw estimate; +- `brier_blended`: the blended probability used for the signals. + +The system adds value only if `brier_blended` is consistently **lower** than `brier_market` +over many markets. With few resolved markets the comparison is not meaningful: `gain_blended` +and `gain_model` give the edge over the price with the 95 % interval (bootstrap on markets). If +the interval includes zero, the edge may be due to chance. + +**Closing price (CLV).** The sync records the last price of each market while it still trades +(`last_trading_price`): it is the «close», the market's estimate when all the information is +known. Consistently buying below the close is the most reliable sign of a real edge, and it +can be measured long before there are enough resolved markets for the Brier score: +- `signal_clv` in Calibration: how much the price moved towards each signal up to the close; +- in the simulated portfolio: close of the side bought − average price paid, per bet and on + average (`clv`; `clv_open` is the move so far on the open ones); +- in the alerts: move from the alert price to the close, in the recommended direction. diff --git a/feeds.yaml b/feeds.yaml index f2b30db..0ef0411 100644 --- a/feeds.yaml +++ b/feeds.yaml @@ -1,468 +1,554 @@ -# Catalogo delle fonti. -# Al primo avvio (tabella fonti vuota) vengono aggiunte le voci con `active: true`. -# Dopo, le fonti si gestiscono dalla dashboard (Impostazioni → Fonti), dove questo -# catalogo compare come elenco di "fonti consigliate" da aggiungere con un clic. +# Source catalogue. +# On the first start (empty sources table) the entries with `active: true` are added. +# After that, sources are managed from the dashboard (Settings → Sources), where this +# catalogue shows up as a list of "recommended sources" to add with one click. # -# category: argomento prevalente, usato come indizio dal classificatore +# category: main topic, used as a hint by the classifier # Politics | Economy | Crypto | Technology | Foreign Affairs | Science | Sports | Culture +# description / description_it: shown in the dashboard in English / Italian feeds: - # --- Notizie generali e geopolitica + # --- General news and geopolitics - name: "BBC News - World" url: "https://feeds.bbci.co.uk/news/world/rss.xml" category: "Foreign Affairs" - description: "Esteri e geopolitica, copertura globale" + description: "World and geopolitics, global coverage" + description_it: "Esteri e geopolitica, copertura globale" active: true - name: "The Guardian - World" url: "https://www.theguardian.com/world/rss" category: "Foreign Affairs" - description: "Esteri, conflitti e diplomazia" + description: "World, conflicts and diplomacy" + description_it: "Esteri, conflitti e diplomazia" active: true - name: "Al Jazeera" url: "https://www.aljazeera.com/xml/rss/all.xml" category: "Foreign Affairs" - description: "Medio Oriente, Africa, Asia" + description: "Middle East, Africa, Asia" + description_it: "Medio Oriente, Africa, Asia" active: true - name: "France 24 - English" url: "https://www.france24.com/en/rss" category: "Foreign Affairs" - description: "Europa e Africa francofona" + description: "Europe and French-speaking Africa" + description_it: "Europa e Africa francofona" active: false - name: "Deutsche Welle" url: "https://rss.dw.com/rdf/rss-en-all" category: "Foreign Affairs" - description: "Germania, Europa centrale e orientale" + description: "Germany, Central and Eastern Europe" + description_it: "Germania, Europa centrale e orientale" active: false - name: "NPR News" url: "https://feeds.npr.org/1001/rss.xml" category: null - description: "Notizie generali USA" + description: "US general news" + description_it: "Notizie generali USA" active: true - # --- Politica ed elezioni + # --- Politics and elections - name: "NPR Politics" url: "https://feeds.npr.org/1014/rss.xml" category: "Politics" - description: "Congresso, Casa Bianca, elezioni USA" + description: "Congress, White House, US elections" + description_it: "Congresso, Casa Bianca, elezioni USA" active: true - name: "Politico" url: "https://rss.politico.com/politics-news.xml" category: "Politics" - description: "Politica USA, sondaggi e campagne" + description: "US politics, polls and campaigns" + description_it: "Politica USA, sondaggi e campagne" active: true - name: "The Hill" url: "https://thehill.com/news/feed/" category: "Politics" - description: "Congresso e amministrazione USA" + description: "US Congress and administration" + description_it: "Congresso e amministrazione USA" active: false - name: "The Guardian - US Politics" url: "https://www.theguardian.com/us-news/us-politics/rss" category: "Politics" - description: "Politica USA" + description: "US politics" + description_it: "Politica USA" active: false - # --- Economia, mercati e banche centrali + # --- Economy, markets and central banks - name: "BBC News - Business" url: "https://feeds.bbci.co.uk/news/business/rss.xml" category: "Economy" - description: "Economia e aziende" + description: "Economy and business" + description_it: "Economia e aziende" active: true - name: "CNBC Top News" url: "https://www.cnbc.com/id/100003114/device/rss/rss.html" category: "Economy" - description: "Mercati USA e aziende" + description: "US markets and business" + description_it: "Mercati USA e aziende" active: true - name: "CNBC Economy" url: "https://www.cnbc.com/id/20910258/device/rss/rss.html" category: "Economy" - description: "Inflazione, lavoro, Fed" + description: "Inflation, jobs, Fed" + description_it: "Inflazione, lavoro, Fed" active: true - name: "MarketWatch - Top Stories" url: "https://feeds.content.dowjones.io/public/rss/mw_topstories" category: "Economy" - description: "Mercati finanziari" + description: "Financial markets" + description_it: "Mercati finanziari" active: false - name: "Federal Reserve - Comunicati" url: "https://www.federalreserve.gov/feeds/press_all.xml" category: "Economy" - description: "Fonte primaria: decisioni e comunicati della Fed" + description: "Primary source: Fed decisions and statements" + description_it: "Fonte primaria: decisioni e comunicati della Fed" active: true - name: "BCE - Comunicati stampa" url: "https://www.ecb.europa.eu/rss/press.html" category: "Economy" - description: "Fonte primaria: decisioni della Banca centrale europea" + description: "Primary source: European Central Bank decisions" + description_it: "Fonte primaria: decisioni della Banca centrale europea" active: true # --- Crypto - name: "CoinDesk" url: "https://www.coindesk.com/arc/outboundfeeds/rss/" category: "Crypto" - description: "Bitcoin, Ethereum, ETF, regolamentazione" + description: "Bitcoin, Ethereum, ETFs, regulation" + description_it: "Bitcoin, Ethereum, ETF, regolamentazione" active: true - name: "Cointelegraph" url: "https://cointelegraph.com/rss" category: "Crypto" - description: "Mercato crypto" + description: "Crypto market" + description_it: "Mercato crypto" active: true - name: "Decrypt" url: "https://decrypt.co/feed" category: "Crypto" - description: "Crypto e Web3" + description: "Crypto and Web3" + description_it: "Crypto e Web3" active: false - # --- Tecnologia e AI + # --- Technology and AI - name: "BBC News - Technology" url: "https://feeds.bbci.co.uk/news/technology/rss.xml" category: "Technology" - description: "Tecnologia" + description: "Technology" + description_it: "Tecnologia" active: true - name: "TechCrunch" url: "https://techcrunch.com/feed/" category: "Technology" - description: "Startup, AI, big tech" + description: "Startups, AI, big tech" + description_it: "Startup, AI, big tech" active: true - name: "The Verge" url: "https://www.theverge.com/rss/index.xml" category: "Technology" - description: "Prodotti, big tech, AI" + description: "Products, big tech, AI" + description_it: "Prodotti, big tech, AI" active: false - name: "Ars Technica" url: "https://feeds.arstechnica.com/arstechnica/index" category: "Technology" - description: "Tecnologia e scienza, analisi approfondite" + description: "Technology and science, in-depth analysis" + description_it: "Tecnologia e scienza, analisi approfondite" active: false - # --- Scienza e salute + # --- Science and health - name: "Nature - News" url: "https://www.nature.com/nature.rss" category: "Science" - description: "Ricerca scientifica" + description: "Scientific research" + description_it: "Ricerca scientifica" active: false - name: "BBC News - Science" url: "https://feeds.bbci.co.uk/news/science_and_environment/rss.xml" category: "Science" - description: "Scienza, clima, ambiente" + description: "Science, climate, environment" + description_it: "Scienza, clima, ambiente" active: false # --- Sport - name: "BBC Sport" url: "https://feeds.bbci.co.uk/sport/rss.xml" category: "Sports" - description: "Calcio, tennis, F1 e altri sport" + description: "Football, tennis, F1 and other sports" + description_it: "Calcio, tennis, F1 e altri sport" active: true - name: "ESPN" url: "https://www.espn.com/espn/rss/news" category: "Sports" - description: "NFL, NBA, MLB e sport USA" + description: "NFL, NBA, MLB and US sports" + description_it: "NFL, NBA, MLB e sport USA" active: true - # --- Italia + # --- Italy - name: "ANSA" url: "https://www.ansa.it/sito/ansait_rss.xml" category: null - description: "Agenzia di stampa italiana" + description: "Italian news agency" + description_it: "Agenzia di stampa italiana" active: false - name: "Il Post" url: "https://www.ilpost.it/feed/" category: null - description: "Notizie italiane e internazionali" + description: "Italian and international news" + description_it: "Notizie italiane e internazionali" active: false - # --- Altre fonti di esteri e geopolitica + # --- More world and geopolitics sources - name: "New York Times - World" url: "https://rss.nytimes.com/services/xml/rss/nyt/World.xml" category: "Foreign Affairs" - description: "Esteri, conflitti e diplomazia" + description: "World, conflicts and diplomacy" + description_it: "Esteri, conflitti e diplomazia" active: true - name: "Washington Post - World" url: "https://feeds.washingtonpost.com/rss/world" category: "Foreign Affairs" - description: "Esteri e sicurezza internazionale" + description: "World and international security" + description_it: "Esteri e sicurezza internazionale" active: false - name: "Sky News - World" url: "https://feeds.skynews.com/feeds/rss/world.xml" category: "Foreign Affairs" - description: "Esteri, aggiornamenti rapidi" + description: "World, quick updates" + description_it: "Esteri, aggiornamenti rapidi" active: false - name: "Euronews" url: "https://www.euronews.com/rss?format=mrss&level=theme&name=news" category: "Foreign Affairs" - description: "Unione europea e paesi europei" + description: "European Union and European countries" + description_it: "Unione europea e paesi europei" active: false - name: "Politico Europe" url: "https://www.politico.eu/feed/" category: "Foreign Affairs" - description: "Politica UE, elezioni europee" + description: "EU politics, European elections" + description_it: "Politica UE, elezioni europee" active: true - name: "Kyiv Independent" url: "https://kyivindependent.com/feed/" category: "Foreign Affairs" - description: "Guerra in Ucraina, negoziati" + description: "War in Ukraine, negotiations" + description_it: "Guerra in Ucraina, negoziati" active: true - name: "Times of Israel" url: "https://www.timesofisrael.com/feed/" category: "Foreign Affairs" - description: "Israele, Gaza, Medio Oriente" + description: "Israel, Gaza, Middle East" + description_it: "Israele, Gaza, Medio Oriente" active: true - name: "South China Morning Post - China" url: "https://www.scmp.com/rss/4/feed" category: "Foreign Affairs" - description: "Cina, Taiwan, Hong Kong" + description: "China, Taiwan, Hong Kong" + description_it: "Cina, Taiwan, Hong Kong" active: false - name: "Nikkei Asia" url: "https://asia.nikkei.com/rss/feed/nar" category: "Foreign Affairs" - description: "Asia: politica ed economia" + description: "Asia: politics and economy" + description_it: "Asia: politica ed economia" active: false - name: "The Hindu - International" url: "https://www.thehindu.com/news/international/feeder/default.rss" category: "Foreign Affairs" - description: "India e Asia meridionale" + description: "India and South Asia" + description_it: "India e Asia meridionale" active: false - name: "Foreign Policy" url: "https://foreignpolicy.com/feed/" category: "Foreign Affairs" - description: "Analisi geopolitiche" + description: "Geopolitical analysis" + description_it: "Analisi geopolitiche" active: false - name: "UN News" url: "https://news.un.org/feed/subscribe/en/news/all/rss.xml" category: "Foreign Affairs" - description: "Fonte primaria: Nazioni Unite, risoluzioni, crisi umanitarie" + description: "Primary source: United Nations, resolutions, humanitarian crises" + description_it: "Fonte primaria: Nazioni Unite, risoluzioni, crisi umanitarie" active: false - # --- Altre fonti di politica, elezioni e sondaggi + # --- More politics, elections and polling sources - name: "New York Times - Politics" url: "https://rss.nytimes.com/services/xml/rss/nyt/Politics.xml" category: "Politics" - description: "Politica USA, Casa Bianca, Congresso" + description: "US politics, White House, Congress" + description_it: "Politica USA, Casa Bianca, Congresso" active: true - name: "Axios" url: "https://api.axios.com/feed/" category: "Politics" - description: "Politica e policy USA, notizie brevi" + description: "US politics and policy, short news" + description_it: "Politica e policy USA, notizie brevi" active: false - name: "Fox News - Politics" url: "https://moxie.foxnews.com/google-publisher/politics.xml" category: "Politics" - description: "Politica USA, area conservatrice" + description: "US politics, conservative outlet" + description_it: "Politica USA, area conservatrice" active: false - name: "RealClearPolitics" url: "https://www.realclearpolitics.com/index.xml" category: "Politics" - description: "Sondaggi e medie dei sondaggi USA" + description: "US polls and polling averages" + description_it: "Sondaggi e medie dei sondaggi USA" active: true - name: "SCOTUSblog" url: "https://www.scotusblog.com/feed/" category: "Politics" - description: "Corte suprema USA: cause e sentenze" + description: "US Supreme Court: cases and rulings" + description_it: "Corte suprema USA: cause e sentenze" active: false - name: "Ballotpedia News" url: "https://news.ballotpedia.org/feed/" category: "Politics" - description: "Elezioni USA, candidati, referendum" + description: "US elections, candidates, ballot measures" + description_it: "Elezioni USA, candidati, referendum" active: false - name: "Roll Call" url: "https://rollcall.com/feed/" category: "Politics" - description: "Congresso USA, voti e leggi" + description: "US Congress, votes and bills" + description_it: "Congresso USA, voti e leggi" active: false - # --- Altre fonti di economia e fonti primarie + # --- More economy sources and primary sources - name: "New York Times - Business" url: "https://rss.nytimes.com/services/xml/rss/nyt/Business.xml" category: "Economy" - description: "Economia e aziende" + description: "Economy and business" + description_it: "Economia e aziende" active: false - name: "Bloomberg - Markets" url: "https://feeds.bloomberg.com/markets/news.rss" category: "Economy" - description: "Mercati, tassi, materie prime" + description: "Markets, rates, commodities" + description_it: "Mercati, tassi, materie prime" active: true - name: "Wall Street Journal - Markets" url: "https://feeds.a.dj.com/rss/RSSMarketsMain.xml" category: "Economy" - description: "Mercati finanziari" + description: "Financial markets" + description_it: "Mercati finanziari" active: false - name: "Yahoo Finance" url: "https://finance.yahoo.com/news/rssindex" category: "Economy" - description: "Borsa, trimestrali, aziende" + description: "Stocks, earnings, companies" + description_it: "Borsa, trimestrali, aziende" active: false - name: "Bureau of Labor Statistics" url: "https://www.bls.gov/feed/bls_latest.rss" category: "Economy" - description: "Fonte primaria: inflazione (CPI), lavoro, disoccupazione" + description: "Primary source: inflation (CPI), jobs, unemployment" + description_it: "Fonte primaria: inflazione (CPI), lavoro, disoccupazione" active: true - name: "Bureau of Economic Analysis" url: "https://apps.bea.gov/rss/rss.xml" category: "Economy" - description: "Fonte primaria: PIL e reddito USA" + description: "Primary source: US GDP and income" + description_it: "Fonte primaria: PIL e reddito USA" active: false - name: "Bank of England" url: "https://www.bankofengland.co.uk/rss/news" category: "Economy" - description: "Fonte primaria: decisioni della Banca d'Inghilterra" + description: "Primary source: Bank of England decisions" + description_it: "Fonte primaria: decisioni della Banca d'Inghilterra" active: false - name: "SEC - Comunicati" url: "https://www.sec.gov/news/pressreleases.rss" category: "Economy" - description: "Fonte primaria: autorità di borsa USA, ETF, cause" + description: "Primary source: US securities regulator, ETFs, lawsuits" + description_it: "Fonte primaria: autorità di borsa USA, ETF, cause" active: true - # --- Altre fonti crypto + # --- More crypto sources - name: "The Block" url: "https://www.theblock.co/rss.xml" category: "Crypto" - description: "Crypto, dati di mercato, regolamentazione" + description: "Crypto, market data, regulation" + description_it: "Crypto, dati di mercato, regolamentazione" active: false - name: "Bitcoin Magazine" url: "https://bitcoinmagazine.com/.rss/full/" category: "Crypto" description: "Bitcoin" + description_it: "Bitcoin" active: false - name: "CryptoSlate" url: "https://cryptoslate.com/feed/" category: "Crypto" - description: "Mercato crypto e altcoin" + description: "Crypto market and altcoins" + description_it: "Mercato crypto e altcoin" active: false - # --- Altre fonti di tecnologia e AI + # --- More technology and AI sources - name: "New York Times - Technology" url: "https://rss.nytimes.com/services/xml/rss/nyt/Technology.xml" category: "Technology" - description: "Big tech, AI, regolamentazione" + description: "Big tech, AI, regulation" + description_it: "Big tech, AI, regolamentazione" active: false - name: "Wired" url: "https://www.wired.com/feed/rss" category: "Technology" - description: "Tecnologia e società" + description: "Technology and society" + description_it: "Tecnologia e società" active: false - name: "MIT Technology Review" url: "https://www.technologyreview.com/feed/" category: "Technology" - description: "AI e tecnologie emergenti" + description: "AI and emerging technologies" + description_it: "AI e tecnologie emergenti" active: false - name: "OpenAI - News" url: "https://openai.com/news/rss.xml" category: "Technology" - description: "Fonte primaria: annunci e nuovi modelli OpenAI" + description: "Primary source: OpenAI announcements and new models" + description_it: "Fonte primaria: annunci e nuovi modelli OpenAI" active: true - name: "Google - AI" url: "https://blog.google/technology/ai/rss/" category: "Technology" - description: "Fonte primaria: annunci AI di Google" + description: "Primary source: Google AI announcements" + description_it: "Fonte primaria: annunci AI di Google" active: false - name: "Hacker News - Prima pagina" url: "https://hnrss.org/frontpage" category: "Technology" - description: "Tech e startup, segnalazioni rapide" + description: "Tech and startups, quick links" + description_it: "Tech e startup, segnalazioni rapide" active: false - name: "Engadget" url: "https://www.engadget.com/rss.xml" category: "Technology" - description: "Prodotti e lanci" + description: "Products and launches" + description_it: "Prodotti e lanci" active: false - # --- Scienza, spazio, salute e meteo + # --- Science, space, health and weather - name: "NASA - Comunicati" url: "https://www.nasa.gov/news-release/feed/" category: "Science" - description: "Fonte primaria: missioni spaziali e lanci" + description: "Primary source: space missions and launches" + description_it: "Fonte primaria: missioni spaziali e lanci" active: false - name: "SpaceNews" url: "https://spacenews.com/feed/" category: "Science" - description: "Lanci, SpaceX, industria spaziale" + description: "Launches, SpaceX, space industry" + description_it: "Lanci, SpaceX, industria spaziale" active: false - name: "OMS - Notizie" url: "https://www.who.int/rss-feeds/news-english.xml" category: "Science" - description: "Fonte primaria: epidemie ed emergenze sanitarie" + description: "Primary source: outbreaks and health emergencies" + description_it: "Fonte primaria: epidemie ed emergenze sanitarie" active: true - name: "NOAA - Uragani (Atlantico)" url: "https://www.nhc.noaa.gov/index-at.xml" category: "Science" - description: "Fonte primaria: allerte uragani, per i mercati meteo" + description: "Primary source: hurricane alerts, for weather markets" + description_it: "Fonte primaria: allerte uragani, per i mercati meteo" active: false - name: "ScienceDaily" url: "https://www.sciencedaily.com/rss/all.xml" category: "Science" - description: "Ricerca scientifica" + description: "Scientific research" + description_it: "Ricerca scientifica" active: false - # --- Altre fonti sportive + # --- More sports sources - name: "BBC Sport - Calcio" url: "https://feeds.bbci.co.uk/sport/football/rss.xml" category: "Sports" description: "Premier League, Champions League" + description_it: "Premier League, Champions League" active: false - name: "ESPN - NFL" url: "https://www.espn.com/espn/rss/nfl/news" category: "Sports" - description: "Football americano" + description: "American football" + description_it: "Football americano" active: false - name: "ESPN - NBA" url: "https://www.espn.com/espn/rss/nba/news" category: "Sports" - description: "Basket NBA" + description: "NBA basketball" + description_it: "Basket NBA" active: false - name: "ESPN - Calcio" url: "https://www.espn.com/espn/rss/soccer/news" category: "Sports" - description: "Calcio internazionale" + description: "International football" + description_it: "Calcio internazionale" active: false - name: "CBS Sports" url: "https://www.cbssports.com/rss/headlines/" category: "Sports" - description: "Sport USA" + description: "US sports" + description_it: "Sport USA" active: false - name: "Autosport - F1" url: "https://www.autosport.com/rss/f1/news/" category: "Sports" description: "Formula 1" + description_it: "Formula 1" active: false - name: "Gazzetta dello Sport" url: "https://www.gazzetta.it/rss/home.xml" category: "Sports" - description: "Sport italiano, Serie A" + description: "Italian sport, Serie A" + description_it: "Sport italiano, Serie A" active: false - # --- Cultura e spettacolo (premi, box office, musica) + # --- Culture and entertainment (awards, box office, music) - name: "Variety" url: "https://variety.com/feed/" category: "Culture" - description: "Cinema, TV, premi (Oscar, Emmy)" + description: "Film, TV, awards (Oscars, Emmys)" + description_it: "Cinema, TV, premi (Oscar, Emmy)" active: true - name: "The Hollywood Reporter" url: "https://www.hollywoodreporter.com/feed/" category: "Culture" - description: "Cinema, TV, box office" + description: "Film, TV, box office" + description_it: "Cinema, TV, box office" active: false - name: "Deadline" url: "https://deadline.com/feed/" category: "Culture" - description: "Box office e industria dello spettacolo" + description: "Box office and the entertainment industry" + description_it: "Box office e industria dello spettacolo" active: false - name: "Billboard" url: "https://www.billboard.com/feed/" category: "Culture" - description: "Musica, classifiche, Grammy" + description: "Music, charts, Grammys" + description_it: "Musica, classifiche, Grammy" active: false - # --- Altre fonti italiane + # --- More Italian sources - name: "Corriere della Sera" url: "https://xml2.corriereobjects.it/rss/homepage.xml" category: null - description: "Notizie italiane e internazionali" + description: "Italian and international news" + description_it: "Notizie italiane e internazionali" active: false - name: "la Repubblica" url: "https://www.repubblica.it/rss/homepage/rss2.0.xml" category: null - description: "Notizie italiane e internazionali" + description: "Italian and international news" + description_it: "Notizie italiane e internazionali" active: false - name: "Rai News" url: "https://www.rainews.it/rss/tutti" category: null - description: "Servizio pubblico, notizie italiane" + description: "Public broadcaster, Italian news" + description_it: "Servizio pubblico, notizie italiane" active: false - # feeds.reuters.com è stato chiuso nel 2020 e non restituisce più notizie + # feeds.reuters.com was shut down in 2020 and no longer returns news diff --git a/frontend/app.js b/frontend/app.js index 08fa201..027bc39 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -1,3 +1,4 @@ +import { t, lang, setLang } from "./i18n.js"; import { h, clear, api, setCsrfToken, fmt, toast, debounce, icon, hydrateIcons, externalLink, signalBadge, impactBadge, marketStateBadge, meter, statTile, emptyState, skeleton, infoTip, GLOSSARY, @@ -68,23 +69,23 @@ function loginView(message) { error.hidden = !message; const username = h("input", { id: "login-username", class: "input", name: "username", autocomplete: "username", required: true, autocapitalize: "none", spellcheck: "false", maxlength: "64" }); const password = h("input", { id: "login-password", class: "input", name: "password", type: "password", autocomplete: "current-password", required: true, maxlength: "256" }); - const reveal = h("button", { class: "input-addon", type: "button", "aria-label": "Mostra password", "aria-pressed": "false" }, icon("eye")); + const reveal = h("button", { class: "input-addon", type: "button", "aria-label": t("Mostra password"), "aria-pressed": "false" }, icon("eye")); reveal.addEventListener("click", () => { const show = password.type === "password"; password.type = show ? "text" : "password"; reveal.setAttribute("aria-pressed", String(show)); - reveal.setAttribute("aria-label", show ? "Nascondi password" : "Mostra password"); + reveal.setAttribute("aria-label", show ? t("Nascondi password") : t("Mostra password")); }); - const submit = h("button", { class: "btn btn-primary btn-block", type: "submit" }, h("span", { class: "spinner", "aria-hidden": "true" }), "Accedi"); + const submit = h("button", { class: "btn btn-primary btn-block", type: "submit" }, h("span", { class: "spinner", "aria-hidden": "true" }), t("Accedi")); const form = h("form", { class: "form", novalidate: true }, - h("div", { class: "form-field" }, h("label", { for: "login-username" }, "Username"), username), - h("div", { class: "form-field" }, h("label", { for: "login-password" }, "Password"), h("div", { class: "input-group" }, password, reveal)), + h("div", { class: "form-field" }, h("label", { for: "login-username" }, t("Username")), username), + h("div", { class: "form-field" }, h("label", { for: "login-password" }, t("Password")), h("div", { class: "input-group" }, password, reveal)), error, submit, ); form.addEventListener("submit", async (e) => { e.preventDefault(); if (!username.value.trim() || !password.value) { - error.textContent = "Inserisci username e password."; + error.textContent = t("Inserisci username e password."); error.hidden = false; (username.value.trim() ? password : username).focus(); return; @@ -118,11 +119,11 @@ function loginView(message) { return h("div", { class: "login-wrap" }, h("section", { class: "card login-card", "aria-labelledby": "login-title" }, h("div", { class: "login-head" }, h("span", { class: "login-icon" }, icon("lock")), h("div", {}, - h("h1", { id: "login-title" }, "Accedi"), - h("p", { class: "secondary small" }, "Dashboard privata di News × Markets."), + h("h1", { id: "login-title" }, t("Accedi")), + h("p", { class: "secondary small" }, t("Dashboard privata di News × Markets.")), )), form, - h("p", { class: "muted small" }, "Non hai un account? Chiedi a chi gestisce il server di crearlo con ", + h("p", { class: "muted small" }, t("Non hai un account? Chiedi a chi gestisce il server di crearlo con "), h("code", {}, "python -m backend.auth.cli create-user"), "."), ), ); @@ -131,11 +132,11 @@ function loginView(message) { function cookieRefusedMessage() { const local = ["localhost", "127.0.0.1", "[::1]"].includes(window.location.hostname); if (window.location.protocol === "http:" && !local) { - return "Password corretta, ma il browser non ha salvato la sessione: il cookie è riservato alle connessioni HTTPS " - + "e stai usando HTTP da un indirizzo di rete. Su una rete di casa fidata imposta SESSION_COOKIE_SECURE=false " - + "nel file .env e riavvia; altrimenti usa HTTPS (per esempio con Cloudflare Tunnel)."; + return t("Password corretta, ma il browser non ha salvato la sessione: il cookie è riservato alle connessioni HTTPS ") + + t("e stai usando HTTP da un indirizzo di rete. Su una rete di casa fidata imposta SESSION_COOKIE_SECURE=false ") + + t("nel file .env e riavvia; altrimenti usa HTTPS (per esempio con Cloudflare Tunnel)."); } - return "Password corretta, ma il browser non ha salvato la sessione. Controlla che i cookie non siano bloccati per questo sito."; + return t("Password corretta, ma il browser non ha salvato la sessione. Controlla che i cookie non siano bloccati per questo sito."); } async function logout() { @@ -143,7 +144,7 @@ async function logout() { await api("/auth/logout", { method: "POST", handle401: false }); } catch { /* the session is gone either way */ } afterLogin = null; - showLogin("Sei uscito. A presto."); + showLogin(t("Sei uscito. A presto.")); window.history.replaceState(null, "", "#/opportunita"); } @@ -165,18 +166,18 @@ function renderBanner() { const banner = document.getElementById("banner"); clear(banner); if (!status) { - banner.append(icon("alert"), h("span", {}, "Il server non risponde. Controlla che l'API sia avviata e che il database sia raggiungibile.")); + banner.append(icon("alert"), h("span", {}, t("Il server non risponde. Controlla che l'API sia avviata e che il database sia raggiungibile."))); banner.hidden = false; } else if (!status.jev_enabled) { banner.append(icon("alert"), h("span", {}, - "Previsioni disattivate: manca la chiave TypeSafe. Aggiungi ", h("code", {}, "TYPESAFE_API_KEY"), - " al file .env e riavvia. Nel frattempo le notizie vengono classificate con un'euristica locale.", + t("Previsioni disattivate: manca la chiave TypeSafe. Aggiungi "), h("code", {}, "TYPESAFE_API_KEY"), + t(" al file .env e riavvia. Nel frattempo le notizie vengono classificate con un'euristica locale."), )); banner.hidden = false; } else if (status.usage?.blocked) { banner.append(icon("alert"), h("span", {}, - "Limite giornaliero delle API AI raggiunto: previsioni, allerte e backtest sono in pausa fino a mezzanotte. ", - h("a", { href: "#/uso" }, "Vedi uso e costi"))); + t("Limite giornaliero delle API AI raggiunto: previsioni, allerte e backtest sono in pausa fino a mezzanotte. "), + h("a", { href: "#/uso" }, t("Vedi uso e costi")))); banner.hidden = false; } else { banner.hidden = true; @@ -186,12 +187,12 @@ function renderBanner() { /** One line in the top bar: is the data fresh and is Jev working? */ function renderStatusLine() { const el = document.getElementById("topbar-status"); - if (!status) return el.replaceChildren(h("span", { class: "dot warn", "aria-hidden": "true" }), "Server non raggiungibile"); + if (!status) return el.replaceChildren(h("span", { class: "dot warn", "aria-hidden": "true" }), t("Server non raggiungibile")); const parts = [ - status.last_article_at ? `Ultima notizia ${fmt.ago(status.last_article_at)}` : "Nessuna notizia ancora", - `${fmt.int(status.open_markets)} mercati aperti`, - `Jev ${status.jev_enabled ? "attivo" : "non configurato"}`, - status.prediction_auto ? "previsioni automatiche" : "previsioni manuali", + status.last_article_at ? t("Ultima notizia {0}", fmt.ago(status.last_article_at)) : t("Nessuna notizia ancora"), + t("{0} mercati aperti", fmt.int(status.open_markets)), + t("Jev {0}", status.jev_enabled ? t("attivo") : t("non configurato")), + status.prediction_auto ? t("previsioni automatiche") : t("previsioni manuali"), ]; el.replaceChildren(h("span", { class: `dot${status.jev_enabled ? "" : " warn"}`, "aria-hidden": "true" }), parts.join(" · ")); el.title = el.textContent; @@ -241,18 +242,25 @@ function setupShell() { setupCollapse(); setupMenu(document.getElementById("btn-jobs"), document.getElementById("jobs-menu")); window.addEventListener("auth:required", () => { - if (me) showLogin("La sessione è scaduta. Accedi di nuovo."); + if (me) showLogin(t("La sessione è scaduta. Accedi di nuovo.")); }); const ingest = document.getElementById("btn-ingest"); const sync = document.getElementById("btn-sync"); - ingest.addEventListener("click", () => runJob(ingest, "/ingest", "Aggiornamento notizie avviato. La pagina si aggiorna da sola.")); - sync.addEventListener("click", () => runJob(sync, "/markets/sync", "Aggiornamento mercati avviato. La pagina si aggiorna da sola.")); + ingest.addEventListener("click", () => runJob(ingest, "/ingest", t("Aggiornamento notizie avviato. La pagina si aggiorna da sola."))); + sync.addEventListener("click", () => runJob(sync, "/markets/sync", t("Aggiornamento mercati avviato. La pagina si aggiorna da sola."))); + + // Language: IT / EN next to the theme button; switching reloads the page in the other language + for (const btn of document.querySelectorAll("#lang-switch [data-lang]")) { + btn.setAttribute("aria-pressed", String(btn.dataset.lang === lang)); + btn.addEventListener("click", () => { if (btn.dataset.lang !== lang) setLang(btn.dataset.lang); }); + } + localizeStatic(); const themeBtn = document.getElementById("btn-theme"); const paintThemeButton = () => { const light = document.documentElement.dataset.theme === "light"; themeBtn.replaceChildren(icon(light ? "moon" : "sun")); - themeBtn.setAttribute("aria-label", light ? "Passa al tema scuro" : "Passa al tema chiaro"); + themeBtn.setAttribute("aria-label", light ? t("Passa al tema scuro") : t("Passa al tema chiaro")); document.querySelector('meta[name="theme-color"]').setAttribute("content", light ? "#f1f5f9" : "#020617"); }; themeBtn.addEventListener("click", () => { @@ -265,6 +273,28 @@ function setupShell() { paintThemeButton(); } +/** The fixed texts of index.html, in the interface language. */ +function localizeStatic() { + const text = (sel, value) => { const el = document.querySelector(sel); if (el) el.textContent = value; }; + const attr = (sel, name, value) => document.querySelectorAll(sel).forEach((el) => el.setAttribute(name, value)); + text(".skip-link", t("Vai al contenuto")); + attr("#sidebar", "aria-label", t("Menu principale")); + attr(".brand", "aria-label", t("News × Markets, vai alle opportunità")); + attr("#side-nav", "aria-label", t("Sezioni")); + text("#btn-jobs .btn-label", t("Aggiorna")); + text("#btn-ingest b", t("Aggiorna notizie")); + text("#btn-ingest .muted", t("Scarica le fonti, classifica e collega ai mercati")); + text("#btn-sync b", t("Aggiorna mercati")); + text("#btn-sync .muted", t("Prezzi da Polymarket e collegamento delle notizie")); + text("#footer p", t("Segnali indicativi, non consulenza finanziaria. L'app non piazza ordini su Polymarket.")); + attr("#bottom-nav", "aria-label", t("Sezioni principali")); + text("#more-title", t("Tutte le sezioni")); + attr("#btn-more-close", "aria-label", t("Chiudi")); + attr("#sheet-nav", "aria-label", t("Tutte le sezioni")); + attr("#lang-switch", "aria-label", t("Lingua")); + attr('meta[name="description"]', "content", t("Notizie classificate con TypeSafe Jev e previsioni sui mercati Polymarket")); +} + // ---------- Router ---------- const routes = [ [/^#\/opportunita$/, "opportunita", viewOpportunities], @@ -306,8 +336,8 @@ async function route({ quiet = false } = {}) { if (!quiet) view.focus({ preventScroll: true }); } catch (e) { if (token !== renderToken || e.status === 401) return; - clear(view).append(emptyState("Impossibile caricare la pagina", e.message, - h("button", { class: "btn btn-ghost", type: "button", on: { click: () => route() } }, icon("refresh"), "Riprova"))); + clear(view).append(emptyState(t("Impossibile caricare la pagina"), e.message, + h("button", { class: "btn btn-ghost", type: "button", on: { click: () => route() } }, icon("refresh"), t("Riprova")))); } } @@ -348,7 +378,7 @@ async function viewOpportunities() { const kpis = h("div", { class: "kpis" }); // Multi-outcome events: a separate block, read as distributions const multiHead = h("div", { class: "section-head", hidden: true }, - h("h2", {}, "Mercati a più esiti"), h("a", { class: "btn btn-ghost btn-sm", href: "#/multi" }, "Tutti gli eventi")); + h("h2", {}, t("Mercati a più esiti")), h("a", { class: "btn btn-ghost btn-sm", href: "#/multi" }, t("Tutti gli eventi"))); const multiList = h("div", { class: "multi-grid" }); const reloadMulti = async () => { try { @@ -367,52 +397,52 @@ async function viewOpportunities() { }); const active = opps.filter((o) => o.prediction.signal !== "HOLD").length; kpis.replaceChildren( - statTile("Mercati aperti", fmt.int(status?.open_markets ?? 0), "binari Sì/No su Polymarket"), - statTile("Con notizie collegate", fmt.int(status?.linked_markets ?? 0), "notizie recenti simili alla domanda"), - statTile("Previsioni Jev", fmt.int(status?.predictions ?? 0), status?.last_prediction_at ? `ultima ${fmt.ago(status.last_prediction_at)}` : "nessuna ancora"), - statTile("Segnali attivi", fmt.int(active), `edge ≥ ${oppFilters.minEdge} pt`), + statTile(t("Mercati aperti"), fmt.int(status?.open_markets ?? 0), t("binari Sì/No su Polymarket")), + statTile(t("Con notizie collegate"), fmt.int(status?.linked_markets ?? 0), t("notizie recenti simili alla domanda")), + statTile(t("Previsioni Jev"), fmt.int(status?.predictions ?? 0), status?.last_prediction_at ? t("ultima {0}", fmt.ago(status.last_prediction_at)) : t("nessuna ancora")), + statTile(t("Segnali attivi"), fmt.int(active), t("edge ≥ {0} pt", oppFilters.minEdge)), ); list.replaceChildren(...(opps.length ? opps.map(opportunityCard) : [opportunitiesEmpty()])); } catch (e) { - list.replaceChildren(emptyState("Impossibile caricare le opportunità", e.message)); + list.replaceChildren(emptyState(t("Impossibile caricare le opportunità"), e.message)); } }; const reloadSoon = debounce(reload, 250); - const filters = h("div", { class: "filters", role: "group", "aria-label": "Filtri" }, - rangeField("f-edge", "Edge minimo", { min: 0, max: 30, step: 1, value: oppFilters.minEdge, format: (v) => `${v} pt` }, (v) => { oppFilters.minEdge = v; reloadSoon(); }), - rangeField("f-evidence", "Evidenze minime", { min: 0, max: 100, step: 5, value: oppFilters.minEvidence, format: (v) => `${v}%` }, (v) => { oppFilters.minEvidence = v; reloadSoon(); }), - checkField("f-hold", "Mostra anche «Attendi»", oppFilters.includeHold, (v) => { oppFilters.includeHold = v; reload(); }), + const filters = h("div", { class: "filters", role: "group", "aria-label": t("Filtri") }, + rangeField("f-edge", t("Edge minimo"), { min: 0, max: 30, step: 1, value: oppFilters.minEdge, format: (v) => `${v} ${t("pt")}` }, (v) => { oppFilters.minEdge = v; reloadSoon(); }), + rangeField("f-evidence", t("Evidenze minime"), { min: 0, max: 100, step: 5, value: oppFilters.minEvidence, format: (v) => `${v}%` }, (v) => { oppFilters.minEvidence = v; reloadSoon(); }), + checkField("f-hold", t("Mostra anche «Attendi»"), oppFilters.includeHold, (v) => { oppFilters.includeHold = v; reload(); }), ); await reload(); const bulk = bulkPredict(ctx); return h("div", {}, - pageHead("Opportunità", "Mercati in cui la stima di Jev, pesata per la forza delle notizie, si discosta dal prezzo. Ordinati per edge.", - h("a", { class: "btn btn-ghost", href: "#/metodo" }, icon("help"), "Come funziona"), bulk.button), + pageHead(t("Opportunità"), t("Mercati in cui la stima di Jev, pesata per la forza delle notizie, si discosta dal prezzo. Ordinati per edge."), + h("a", { class: "btn btn-ghost", href: "#/metodo" }, icon("help"), t("Come funziona")), bulk.button), bulk.panel, kpis, filters, list, h("div", { class: "opps-multi" }, multiHead, multiList), ); } function opportunitiesEmpty() { - if (!status) return emptyState("Nessun dato", "Il server non risponde."); + if (!status) return emptyState(t("Nessun dato"), t("Il server non risponde.")); if (status.open_markets === 0) { - return emptyState("Nessun mercato ancora", isAdmin() ? "Scarica i mercati Sì/No più scambiati da Polymarket per iniziare." : "Un amministratore deve scaricare i mercati da Polymarket.", - isAdmin() ? h("button", { class: "btn btn-primary", type: "button", on: { click: () => document.getElementById("btn-sync").click() } }, icon("sync"), "Aggiorna mercati") : null); + return emptyState(t("Nessun mercato ancora"), isAdmin() ? t("Scarica i mercati Sì/No più scambiati da Polymarket per iniziare.") : t("Un amministratore deve scaricare i mercati da Polymarket."), + isAdmin() ? h("button", { class: "btn btn-primary", type: "button", on: { click: () => document.getElementById("btn-sync").click() } }, icon("sync"), t("Aggiorna mercati")) : null); } if (status.linked_markets === 0) { - return emptyState("Nessuna notizia collegata ai mercati", "Le notizie recenti non somigliano ancora a nessuna domanda di mercato. Aggiorna le notizie o abbassa MARKET_MATCH_THRESHOLD nel file .env.", - isAdmin() ? h("button", { class: "btn btn-primary", type: "button", on: { click: () => document.getElementById("btn-ingest").click() } }, icon("refresh"), "Aggiorna notizie") : null); + return emptyState(t("Nessuna notizia collegata ai mercati"), t("Le notizie recenti non somigliano ancora a nessuna domanda di mercato. Aggiorna le notizie o abbassa MARKET_MATCH_THRESHOLD nel file .env."), + isAdmin() ? h("button", { class: "btn btn-primary", type: "button", on: { click: () => document.getElementById("btn-ingest").click() } }, icon("refresh"), t("Aggiorna notizie")) : null); } if (!status.jev_enabled) { - return emptyState("Previsioni non disponibili", "Configura TYPESAFE_API_KEY per ottenere le stime di Jev sui mercati con notizie collegate."); + return emptyState(t("Previsioni non disponibili"), t("Configura TYPESAFE_API_KEY per ottenere le stime di Jev sui mercati con notizie collegate.")); } if (status.predictions === 0) { - return emptyState("Nessuna previsione ancora", `${fmt.int(status.linked_markets)} mercati hanno notizie collegate. Apri un mercato e chiedi una previsione a Jev.`, - h("a", { class: "btn btn-primary", href: "#/mercati" }, "Vedi i mercati con notizie")); + return emptyState(t("Nessuna previsione ancora"), t("{0} mercati hanno notizie collegate. Apri un mercato e chiedi una previsione a Jev.", fmt.int(status.linked_markets)), + h("a", { class: "btn btn-primary", href: "#/mercati" }, t("Vedi i mercati con notizie"))); } - return emptyState("Nessuna opportunità con questi filtri", "Abbassa l'edge o le evidenze minime, oppure mostra anche i mercati in attesa."); + return emptyState(t("Nessuna opportunità con questi filtri"), t("Abbassa l'edge o le evidenze minime, oppure mostra anche i mercati in attesa.")); } function opportunityCard({ market, prediction: p }) { @@ -421,31 +451,31 @@ function opportunityCard({ market, prediction: p }) { h("div", {}, h("a", { class: "opp-title", href: marketHref(market.id) }, market.question), h("div", { class: "opp-meta" }, - h("span", {}, `Scade ${fmt.date(market.end_date)}`), - h("span", {}, `Volume ${fmt.usd(market.volume)}`), - h("span", {}, `${p.article_count} notizie analizzate`), - h("span", { title: fmt.dateTime(p.created_at) }, `Previsione ${fmt.ago(p.created_at)}`), + h("span", {}, t("Scade {0}", fmt.date(market.end_date))), + h("span", {}, t("Volume {0}", fmt.usd(market.volume))), + h("span", {}, t("{0} notizie analizzate", p.article_count)), + h("span", { title: fmt.dateTime(p.created_at) }, t("Previsione {0}", fmt.ago(p.created_at))), ), probTrack({ market: p.market_probability, blended: p.blended_probability, jev: p.model_probability }), probLegend({ market: p.market_probability, blended: p.blended_probability, jev: p.model_probability }), - h("p", { class: "opp-explain" }, explainSentence(p, status), " ", h("a", { href: marketHref(market.id) }, "Vedi il calcolo")), + h("p", { class: "opp-explain" }, explainSentence(p, status), " ", h("a", { href: marketHref(market.id) }, t("Vedi il calcolo"))), planLine(p.economics), ), h("div", { class: "opp-side" }, h("div", { class: "opp-side-top" }, signalBadge(p.signal), - market.url ? externalLink(market.url, "Polymarket ", icon("external")) : null, + market.url ? externalLink(market.url, t("Polymarket "), icon("external")) : null, ), h("div", { class: "opp-figures" }, - h("div", {}, h("div", { class: "fig-label" }, "Edge", infoTip(GLOSSARY.edge)), h("div", { class: `fig-value ${edgeCls}` }, fmt.pts(p.edge))), + h("div", {}, h("div", { class: "fig-label" }, t("Edge"), infoTip(GLOSSARY.edge)), h("div", { class: `fig-value ${edgeCls}` }, fmt.pts(p.edge))), p.economics - ? h("div", {}, h("div", { class: "fig-label" }, "Conviene?", infoTip("Valutazione economica al momento della previsione: prezzo reale dal book, costi, incertezza, tempo e limiti del preset.")), + ? h("div", {}, h("div", { class: "fig-label" }, t("Conviene?"), infoTip(t("Valutazione economica al momento della previsione: prezzo reale dal book, costi, incertezza, tempo e limiti del preset."))), verdictBadge(p.economics.verdict), - p.economics.verdict !== "NO" ? h("div", { class: "fig-label", style: { marginTop: "4px" } }, `Puntata ${fmt.money(p.economics.outlay)}`) : null) - : h("div", {}, h("div", { class: "fig-label" }, "Puntata suggerita", infoTip(GLOSSARY.kelly)), h("div", { class: "fig-value" }, p.kelly_fraction > 0 ? `${fmt.pct(p.kelly_fraction)}` : "–"), - p.kelly_fraction > 0 ? h("div", { class: "fig-label" }, "del bankroll") : null), + p.economics.verdict !== "NO" ? h("div", { class: "fig-label", style: { marginTop: "4px" } }, t("Puntata {0}", fmt.money(p.economics.outlay))) : null) + : h("div", {}, h("div", { class: "fig-label" }, t("Puntata suggerita"), infoTip(GLOSSARY.kelly)), h("div", { class: "fig-value" }, p.kelly_fraction > 0 ? `${fmt.pct(p.kelly_fraction)}` : "–"), + p.kelly_fraction > 0 ? h("div", { class: "fig-label" }, t("del bankroll")) : null), ), - h("div", {}, h("div", { class: "fig-label" }, "Forza delle evidenze", infoTip(GLOSSARY.evidence)), meter(p.evidence_strength, "Forza delle evidenze")), + h("div", {}, h("div", { class: "fig-label" }, t("Forza delle evidenze"), infoTip(GLOSSARY.evidence)), meter(p.evidence_strength, t("Forza delle evidenze"))), ), ); } @@ -453,14 +483,14 @@ function opportunityCard({ market, prediction: p }) { // ---------- Mercati ---------- // key -> label, default direction, direction wording (what "asc" and "desc" mean for this field) const MARKET_SORTS = { - volume: { label: "Volume", dir: "desc", asc: "dal più basso", desc: "dal più alto" }, - end_date: { label: "Scadenza", dir: "asc", asc: "prima i più vicini", desc: "prima i più lontani" }, - price: { label: "Prezzo SÌ", dir: "desc", asc: "dal più basso", desc: "dal più alto" }, - signal: { label: "Ultimo segnale", dir: "desc", asc: "prima i meno recenti", desc: "prima i più recenti" }, - edge: { label: "Edge", dir: "desc", asc: "dal più piccolo", desc: "dal più grande" }, - news: { label: "Notizie collegate", dir: "desc", asc: "prima le meno", desc: "prima le più" }, - liquidity: { label: "Liquidità", dir: "desc", asc: "dalla più bassa", desc: "dalla più alta" }, - question: { label: "Nome", dir: "asc", asc: "A → Z", desc: "Z → A" }, + volume: { label: t("Volume"), dir: "desc", asc: t("dal più basso"), desc: t("dal più alto") }, + end_date: { label: t("Scadenza"), dir: "asc", asc: t("prima i più vicini"), desc: t("prima i più lontani") }, + price: { label: t("Prezzo SÌ"), dir: "desc", asc: t("dal più basso"), desc: t("dal più alto") }, + signal: { label: t("Ultimo segnale"), dir: "desc", asc: t("prima i meno recenti"), desc: t("prima i più recenti") }, + edge: { label: t("Edge"), dir: "desc", asc: t("dal più piccolo"), desc: t("dal più grande") }, + news: { label: t("Notizie collegate"), dir: "desc", asc: t("prima le meno"), desc: t("prima le più") }, + liquidity: { label: t("Liquidità"), dir: "desc", asc: t("dalla più bassa"), desc: t("dalla più alta") }, + question: { label: t("Nome"), dir: "asc", asc: t("A → Z"), desc: t("Z → A") }, }; const marketFilters = { q: "", onlyLinked: null, includeClosed: false, sort: "volume", order: null }; try { @@ -504,9 +534,9 @@ async function viewMarkets() { offset += data.markets.length; const s = MARKET_SORTS[marketFilters.sort]; summary.textContent = data.total - ? `${fmt.int(offset)} di ${fmt.count(data.total, "mercato", "mercati")}, ordinati per ${s.label.charAt(0).toLowerCase() + s.label.slice(1)} (${s[sortDir()]})` : ""; + ? t("{0} di {1}, ordinati per {2} ({3})", fmt.int(offset), fmt.count(data.total, t("mercato"), t("mercati")), s.label.charAt(0).toLowerCase() + s.label.slice(1), s[sortDir()]) : ""; more.replaceChildren(offset < data.total - ? h("button", { class: "btn btn-ghost", type: "button", on: { click: () => load(false) } }, "Carica altri") + ? h("button", { class: "btn btn-ghost", type: "button", on: { click: () => load(false) } }, t("Carica altri")) : ""); tableWrap.hidden = data.total === 0; empty.hidden = data.total !== 0; @@ -516,15 +546,15 @@ async function viewMarkets() { // Column headers: click sorts, a second click on the same column reverses the direction const COLUMNS = [ - ["question", "Mercato", ""], ["price", "Prezzo SÌ", "num"], ["volume", "Volume", "num"], ["liquidity", "Liquidità", "num"], - ["end_date", "Scadenza", ""], ["news", "Notizie", "num"], ["signal", "Ultimo segnale", ""], ["edge", "Edge", "num"], + ["question", t("Mercato"), ""], ["price", t("Prezzo SÌ"), "num"], ["volume", t("Volume"), "num"], ["liquidity", t("Liquidità"), "num"], + ["end_date", t("Scadenza"), ""], ["news", t("Notizie"), "num"], ["signal", t("Ultimo segnale"), ""], ["edge", t("Edge"), "num"], ]; function paintSort() { const dir = sortDir(); headRow.replaceChildren(...COLUMNS.map(([key, label, cls]) => { const active = marketFilters.sort === key; const btn = h("button", { class: `th-sort${active ? " active" : ""}`, type: "button", - title: active ? `Ordinato per ${label.toLowerCase()}, ${MARKET_SORTS[key][dir]}. Clic per invertire` : `Ordina per ${label.toLowerCase()}` }, + title: active ? t("Ordinato per {0}, {1}. Clic per invertire", label.toLowerCase(), MARKET_SORTS[key][dir]) : t("Ordina per {0}", label.toLowerCase()) }, label, icon(active ? (dir === "asc" ? "arrowUp" : "arrowDown") : "sort", `icon-svg sort-icon${active ? "" : " sort-idle"}`)); btn.addEventListener("click", () => { if (active) setMarketSort(key, dir === "asc" ? "desc" : "asc"); @@ -538,32 +568,32 @@ async function viewMarkets() { Object.entries(MARKET_SORTS).map(([key, s]) => h("option", { value: key, selected: key === marketFilters.sort }, s.label))); select.addEventListener("change", () => { setMarketSort(select.value, null); reload(); }); const s = MARKET_SORTS[marketFilters.sort]; - const flip = h("button", { class: "btn btn-ghost", type: "button", "aria-label": `Direzione: ${s[dir]}. Clic per invertire` }, + const flip = h("button", { class: "btn btn-ghost", type: "button", "aria-label": t("Direzione: {0}. Clic per invertire", s[dir]) }, icon(dir === "asc" ? "arrowUp" : "arrowDown"), s[dir]); flip.addEventListener("click", () => { setMarketSort(marketFilters.sort, dir === "asc" ? "desc" : "asc"); reload(); }); - sortControls.replaceChildren(h("label", { class: "field", for: "m-sort" }, "Ordina per", select), flip); + sortControls.replaceChildren(h("label", { class: "field", for: "m-sort" }, t("Ordina per"), select), flip); } - const search = h("input", { id: "m-search", class: "search", type: "search", placeholder: "Cerca un mercato (es. Fed, elezioni, Bitcoin)", value: marketFilters.q, "aria-label": "Cerca un mercato" }); + const search = h("input", { id: "m-search", class: "search", type: "search", placeholder: t("Cerca un mercato (es. Fed, elezioni, Bitcoin)"), value: marketFilters.q, "aria-label": t("Cerca un mercato") }); search.addEventListener("input", () => { marketFilters.q = search.value.trim(); reloadSoon(); }); const tableWrap = h("div", { class: "table-wrap" }, h("table", { class: "markets-table" }, h("thead", {}, headRow), tbody), ); - const empty = emptyState("Nessun mercato trovato", - marketFilters.onlyLinked ? "Nessun mercato con notizie collegate corrisponde alla ricerca. Togli il filtro «Solo con notizie» per vederli tutti." : "Aggiorna i mercati da Polymarket o cambia la ricerca."); + const empty = emptyState(t("Nessun mercato trovato"), + marketFilters.onlyLinked ? t("Nessun mercato con notizie collegate corrisponde alla ricerca. Togli il filtro «Solo con notizie» per vederli tutti.") : t("Aggiorna i mercati da Polymarket o cambia la ricerca.")); empty.hidden = true; paintSort(); await load(true); const bulk = bulkPredict(ctx); return h("div", {}, - pageHead("Mercati", "I mercati Sì/No più scambiati su Polymarket. Il prezzo in centesimi è la probabilità implicita del SÌ.", bulk.button), + pageHead(t("Mercati"), t("I mercati Sì/No più scambiati su Polymarket. Il prezzo in centesimi è la probabilità implicita del SÌ."), bulk.button), bulk.panel, - h("div", { class: "filters", role: "group", "aria-label": "Filtri e ordinamento" }, + h("div", { class: "filters", role: "group", "aria-label": t("Filtri e ordinamento") }, h("div", { class: "search-wrap" }, icon("search"), search), - checkField("m-linked", "Solo con notizie", marketFilters.onlyLinked, (v) => { marketFilters.onlyLinked = v; reloadSoon(); }), - checkField("m-closed", "Includi chiusi", marketFilters.includeClosed, (v) => { marketFilters.includeClosed = v; reloadSoon(); }), + checkField("m-linked", t("Solo con notizie"), marketFilters.onlyLinked, (v) => { marketFilters.onlyLinked = v; reloadSoon(); }), + checkField("m-closed", t("Includi chiusi"), marketFilters.includeClosed, (v) => { marketFilters.includeClosed = v; reloadSoon(); }), sortControls, ), h("div", { class: "stack" }, summary, tableWrap, empty, more), @@ -573,7 +603,7 @@ async function viewMarkets() { function daysLeft(end) { if (!end) return null; const days = Math.ceil((new Date(end).getTime() - Date.now()) / 86_400_000); - return days < 0 ? "scaduto" : days === 0 ? "oggi" : days === 1 ? "domani" : `tra ${fmt.int(days)} gg`; + return days < 0 ? t("scaduto") : days === 0 ? t("oggi") : days === 1 ? t("domani") : t("tra {0} gg", fmt.int(days)); } function marketRow(m) { @@ -588,7 +618,7 @@ function marketRow(m) { h("td", { class: "num" }, fmt.int(m.linked_articles)), h("td", { class: "nowrap" }, p ? [signalBadge(p.signal), h("div", { class: "muted small", title: fmt.dateTime(p.created_at) }, fmt.ago(p.created_at))] - : m.closed ? marketStateBadge(m) : h("span", { class: "muted small" }, "Nessuna previsione")), + : m.closed ? marketStateBadge(m) : h("span", { class: "muted small" }, t("Nessuna previsione"))), h("td", { class: `num ${p ? (p.edge > 0 ? "pos" : p.edge < 0 ? "neg" : "") : ""}` }, p ? fmt.pts(p.edge) : "–"), ); return row; @@ -599,18 +629,18 @@ async function viewMarketDetail(id) { const [market] = await Promise.all([api(`/markets/${encodeURIComponent(id)}`), status ? null : loadStatus()]); const latest = market.latest_prediction; - const predictBtn = h("button", { class: "btn btn-primary", type: "button" }, h("span", { class: "spinner", "aria-hidden": "true" }), icon("refresh"), latest ? "Nuova previsione" : "Chiedi una previsione a Jev"); + const predictBtn = h("button", { class: "btn btn-primary", type: "button" }, h("span", { class: "spinner", "aria-hidden": "true" }), icon("refresh"), latest ? t("Nuova previsione") : t("Chiedi una previsione a Jev")); let blocker = null; - if (!isAdmin()) blocker = "Solo gli amministratori possono chiedere previsioni (sono chiamate a pagamento)."; - else if (!status?.jev_enabled) blocker = "Serve TYPESAFE_API_KEY nel file .env."; - else if (market.closed) blocker = "Il mercato è chiuso."; - else if (market.evidence.length === 0) blocker = "Nessuna notizia recente collegata a questo mercato."; + if (!isAdmin()) blocker = t("Solo gli amministratori possono chiedere previsioni (sono chiamate a pagamento)."); + else if (!status?.jev_enabled) blocker = t("Serve TYPESAFE_API_KEY nel file .env."); + else if (market.closed) blocker = t("Il mercato è chiuso."); + else if (market.evidence.length === 0) blocker = t("Nessuna notizia recente collegata a questo mercato."); predictBtn.disabled = Boolean(blocker); predictBtn.addEventListener("click", async () => { setBusy(predictBtn, true); try { const p = await api(`/markets/${encodeURIComponent(id)}/predict`, { method: "POST" }); - toast(`Previsione salvata: ${p.signal === "BUY_YES" ? "Compra SÌ" : p.signal === "BUY_NO" ? "Compra NO" : "Attendi"} (edge ${fmt.pts(p.edge)})`); + toast(t("Previsione salvata: {0} (edge {1})", p.signal === "BUY_YES" ? t("Compra SÌ") : p.signal === "BUY_NO" ? t("Compra NO") : t("Attendi"), fmt.pts(p.edge))); await loadStatus(); route({ quiet: true }); } catch (e) { @@ -620,38 +650,38 @@ async function viewMarketDetail(id) { }); const forecastCard = h("section", { class: "card", "aria-labelledby": "h-forecast" }, - h("div", { class: "card-head" }, h("h2", { id: "h-forecast" }, "Ultima previsione"), latest ? signalBadge(latest.signal) : null), + h("div", { class: "card-head" }, h("h2", { id: "h-forecast" }, t("Ultima previsione")), latest ? signalBadge(latest.signal) : null), latest ? h("div", {}, probTrack({ market: latest.market_probability, blended: latest.blended_probability, jev: latest.model_probability }), probLegend({ market: latest.market_probability, blended: latest.blended_probability, jev: latest.model_probability }), h("div", { class: "opp-figures", style: { marginTop: "16px", gridTemplateColumns: "repeat(3, minmax(0, 1fr))" } }, - h("div", {}, h("div", { class: "fig-label" }, "Edge"), h("div", { class: `fig-value ${latest.edge > 0 ? "pos" : latest.edge < 0 ? "neg" : ""}` }, fmt.pts(latest.edge))), - h("div", {}, h("div", { class: "fig-label" }, "Puntata"), h("div", { class: "fig-value" }, latest.kelly_fraction > 0 ? fmt.pct(latest.kelly_fraction) : "–")), - h("div", {}, h("div", { class: "fig-label" }, "Notizie"), h("div", { class: "fig-value" }, fmt.int(latest.article_count))), + h("div", {}, h("div", { class: "fig-label" }, t("Edge")), h("div", { class: `fig-value ${latest.edge > 0 ? "pos" : latest.edge < 0 ? "neg" : ""}` }, fmt.pts(latest.edge))), + h("div", {}, h("div", { class: "fig-label" }, t("Puntata")), h("div", { class: "fig-value" }, latest.kelly_fraction > 0 ? fmt.pct(latest.kelly_fraction) : "–")), + h("div", {}, h("div", { class: "fig-label" }, t("Notizie")), h("div", { class: "fig-value" }, fmt.int(latest.article_count))), ), - h("div", { style: { marginTop: "12px" } }, h("div", { class: "fig-label" }, "Forza delle evidenze"), meter(latest.evidence_strength, "Forza delle evidenze")), - h("p", { class: "muted small", style: { marginTop: "10px" } }, `Calcolata ${fmt.ago(latest.created_at)} con ${latest.model_name || "Jev"}. Il prezzo usato è quello di quel momento.`), + h("div", { style: { marginTop: "12px" } }, h("div", { class: "fig-label" }, t("Forza delle evidenze")), meter(latest.evidence_strength, t("Forza delle evidenze"))), + h("p", { class: "muted small", style: { marginTop: "10px" } }, t("Calcolata {0} con {1}. Il prezzo usato è quello di quel momento.", fmt.ago(latest.created_at), latest.model_name || t("Jev"))), ) - : h("p", { class: "secondary" }, "Nessuna previsione per questo mercato. Jev legge le regole del mercato e le notizie collegate e stima la probabilità del SÌ, senza vedere il prezzo."), - h("div", { class: "predict-bar" }, predictBtn, blocker ? h("span", { class: "muted small" }, blocker) : h("span", { class: "muted small" }, "Una chiamata all'API TypeSafe. Il prezzo viene aggiornato prima.")), + : h("p", { class: "secondary" }, t("Nessuna previsione per questo mercato. Jev legge le regole del mercato e le notizie collegate e stima la probabilità del SÌ, senza vedere il prezzo.")), + h("div", { class: "predict-bar" }, predictBtn, blocker ? h("span", { class: "muted small" }, blocker) : h("span", { class: "muted small" }, t("Una chiamata all'API TypeSafe. Il prezzo viene aggiornato prima."))), ); const historyCard = h("section", { class: "card", "aria-labelledby": "h-history" }, - h("div", { class: "card-head" }, h("h2", { id: "h-history" }, "Storico delle previsioni"), h("span", { class: "muted small" }, `${market.predictions.length} previsioni`)), - market.predictions.length ? historyChart(market.predictions) : h("p", { class: "secondary" }, "Lo storico compare dopo la prima previsione."), + h("div", { class: "card-head" }, h("h2", { id: "h-history" }, t("Storico delle previsioni")), h("span", { class: "muted small" }, t("{0} previsioni", market.predictions.length))), + market.predictions.length ? historyChart(market.predictions) : h("p", { class: "secondary" }, t("Lo storico compare dopo la prima previsione.")), ); const sentToJev = status?.market_max_articles ?? 8; let searchBtn = null; if (isAdmin() && !market.closed && status?.targeted_news_enabled) { - searchBtn = h("button", { class: "btn btn-ghost btn-sm", type: "button", title: "Cerca su Google News le notizie delle ultime ore su questo mercato" }, - h("span", { class: "spinner", "aria-hidden": "true" }), icon("search"), "Cerca notizie"); + searchBtn = h("button", { class: "btn btn-ghost btn-sm", type: "button", title: t("Cerca su Google News le notizie delle ultime ore su questo mercato") }, + h("span", { class: "spinner", "aria-hidden": "true" }), icon("search"), t("Cerca notizie")); searchBtn.addEventListener("click", async () => { setBusy(searchBtn, true); try { const r = await api(`/markets/${encodeURIComponent(id)}/search-news`, { method: "POST" }); - toast(r.added ? `${fmt.count(r.added, "notizia nuova", "notizie nuove")} trovate con «${r.query}».` : `Nessuna notizia nuova per «${r.query}».`); + toast(r.added ? t("{0} trovate con «{1}».", fmt.count(r.added, t("notizia nuova"), t("notizie nuove")), r.query) : t("Nessuna notizia nuova per «{0}».", r.query)); route({ quiet: true }); } catch (e) { toast(e.message, { error: true }); @@ -662,11 +692,11 @@ async function viewMarketDetail(id) { const evidenceCard = h("section", { class: "card", "aria-labelledby": "h-evidence" }, h("div", { class: "card-head" }, h("div", {}, - h("h2", { id: "h-evidence" }, "Notizie collegate"), - h("p", { class: "muted small" }, `Una per storia, dalla più utile. Le prime ${sentToJev} vengono lette da Jev.`), + h("h2", { id: "h-evidence" }, t("Notizie collegate")), + h("p", { class: "muted small" }, t("Una per storia, dalla più utile. Le prime {0} vengono lette da Jev.", sentToJev)), ), h("div", { class: "actions" }, - infoTip("Pertinenza: somiglianza di significato tra notizia e domanda, più la presenza dei termini chiave (nomi, sigle, numeri). Utilità: pertinenza × affidabilità della fonte × freschezza × giudizio di Jev. Rilevanza e impatto: il giudizio di Jev dopo l'ultima previsione.", "Come sono ordinate?"), + infoTip(t("Pertinenza: somiglianza di significato tra notizia e domanda, più la presenza dei termini chiave (nomi, sigle, numeri). Utilità: pertinenza × affidabilità della fonte × freschezza × giudizio di Jev. Rilevanza e impatto: il giudizio di Jev dopo l'ultima previsione."), t("Come sono ordinate?")), searchBtn), ), market.evidence.length @@ -676,33 +706,33 @@ async function viewMarketDetail(id) { h("div", { class: "article-meta", style: { margin: "4px 0 0" } }, h("span", {}, ev.source_name), h("span", { title: fmt.dateTime(ev.published_at) }, fmt.ago(ev.published_at)), - ev.corroboration > 1 ? h("span", { class: "badge badge-outline", title: "Fonti diverse che hanno riportato la stessa notizia" }, `${ev.corroboration} fonti`) : null, - ev.targeted ? h("span", { class: "badge badge-outline", title: "Trovata dalla ricerca mirata per questo mercato" }, icon("search"), "ricerca mirata") : null, + ev.corroboration > 1 ? h("span", { class: "badge badge-outline", title: t("Fonti diverse che hanno riportato la stessa notizia") }, t("{0} fonti", ev.corroboration)) : null, + ev.targeted ? h("span", { class: "badge badge-outline", title: t("Trovata dalla ricerca mirata per questo mercato") }, icon("search"), t("ricerca mirata")) : null, ), - ev.matched_terms?.length ? h("div", { class: "terms", "aria-label": "Termini chiave trovati" }, - ev.matched_terms.map((t) => h("span", { class: "term" }, t))) : null, + ev.matched_terms?.length ? h("div", { class: "terms", "aria-label": t("Termini chiave trovati") }, + ev.matched_terms.map((x) => h("span", { class: "term" }, x))) : null, ), h("div", { class: "ev-stats" }, - h("span", {}, "Pertinenza ", h("b", { class: "mono" }, fmt.pct(ev.match_score ?? ev.similarity))), - ev.relevance != null ? h("span", {}, "Rilevanza ", h("b", { class: "mono" }, fmt.pct(ev.relevance))) : null, + h("span", {}, t("Pertinenza "), h("b", { class: "mono" }, fmt.pct(ev.match_score ?? ev.similarity))), + ev.relevance != null ? h("span", {}, t("Rilevanza "), h("b", { class: "mono" }, fmt.pct(ev.relevance))) : null, impactBadge(ev.impact), ), ))) - : h("p", { class: "secondary" }, "Nessuna notizia recente riguarda questo mercato."), + : h("p", { class: "secondary" }, t("Nessuna notizia recente riguarda questo mercato.")), ); return h("div", {}, - h("a", { class: "back", href: "#/mercati" }, icon("back"), "Tutti i mercati"), + h("a", { class: "back", href: "#/mercati" }, icon("back"), t("Tutti i mercati")), h("div", { class: "card", style: { marginBottom: "16px" } }, - h("div", { class: "eyebrow" }, "Mercato Polymarket"), + h("div", { class: "eyebrow" }, t("Mercato Polymarket")), h("h1", { style: { marginTop: "4px" } }, market.question), h("div", { class: "meta-row" }, marketStateBadge(market), - h("span", {}, "Prezzo SÌ ", h("b", { class: "mono", style: { color: "var(--text-primary)" } }, fmt.cents(market.yes_price))), - h("span", {}, `Scade ${fmt.date(market.end_date)}`), - h("span", {}, `Volume ${fmt.usd(market.volume)}`), - h("span", {}, `Liquidità ${fmt.usd(market.liquidity)}`), - market.url ? externalLink(market.url, "Apri su Polymarket", icon("external")) : null, + h("span", {}, t("Prezzo SÌ "), h("b", { class: "mono", style: { color: "var(--text-primary)" } }, fmt.cents(market.yes_price))), + h("span", {}, t("Scade {0}", fmt.date(market.end_date))), + h("span", {}, t("Volume {0}", fmt.usd(market.volume))), + h("span", {}, t("Liquidità {0}", fmt.usd(market.liquidity))), + market.url ? externalLink(market.url, t("Apri su Polymarket"), icon("external")) : null, ), ), h("div", { class: "grid-2", style: { marginBottom: "16px" } }, forecastCard, historyCard), @@ -710,7 +740,7 @@ async function viewMarketDetail(id) { latest ? economicsCard(ctx, market) : null, latest ? explainCard(latest, market, market.evidence, status) : null, evidenceCard, - market.description ? h("details", { class: "card rules" }, h("summary", {}, "Regole di risoluzione"), h("p", { class: "rules-text" }, market.description)) : null, + market.description ? h("details", { class: "card rules" }, h("summary", {}, t("Regole di risoluzione")), h("p", { class: "rules-text" }, market.description)) : null, ), ); } @@ -718,51 +748,51 @@ async function viewMarketDetail(id) { // ---------- Calibrazione ---------- async function viewCalibration() { const cal = await api("/predictions/calibration"); - const head = pageHead("Calibrazione", "Quanto sono state accurate le previsioni sui mercati già risolti, rispetto al prezzo di mercato."); + const head = pageHead(t("Calibrazione"), t("Quanto sono state accurate le previsioni sui mercati già risolti, rispetto al prezzo di mercato.")); if (!cal.resolved_markets) { - return h("div", {}, head, emptyState("Ancora nessun mercato risolto", - "Quando un mercato con almeno una previsione si chiude, qui trovi il confronto tra Jev, blended e prezzo. Servono decine di mercati risolti prima di fidarsi dei segnali.")); + return h("div", {}, head, emptyState(t("Ancora nessun mercato risolto"), + t("Quando un mercato con almeno una previsione si chiude, qui trovi il confronto tra Jev, blended e prezzo. Servono decine di mercati risolti prima di fidarsi dei segnali."))); } const better = cal.brier_blended != null && cal.brier_market != null ? (cal.brier_market - cal.brier_blended) / cal.brier_market : null; const small = cal.resolved_markets < 30; const g = cal.gain_blended; const interval = g && g.lo != null - ? (g.lo > 0 ? "Anche nel caso peggiore dell'intervallo al 95% il blended batte il prezzo: il vantaggio non sembra dovuto al caso." - : g.hi < 0 ? "Anche nel caso migliore dell'intervallo al 95% il prezzo fa meglio del blended." - : "L'intervallo al 95% comprende lo zero: con questi mercati non si può ancora dire chi sia più accurato.") - + ` (vantaggio in Brier ${fmt.num3(g.mean)}, da ${fmt.num3(g.lo)} a ${fmt.num3(g.hi)})` + ? (g.lo > 0 ? t("Anche nel caso peggiore dell'intervallo al 95% il blended batte il prezzo: il vantaggio non sembra dovuto al caso.") + : g.hi < 0 ? t("Anche nel caso migliore dell'intervallo al 95% il prezzo fa meglio del blended.") + : t("L'intervallo al 95% comprende lo zero: con questi mercati non si può ancora dire chi sia più accurato.")) + + t(" (vantaggio in Brier {0}, da {1} a {2})", fmt.num3(g.mean), fmt.num3(g.lo), fmt.num3(g.hi)) : null; const sc = cal.signal_clv; return h("div", {}, head, h("div", { class: "grid-2" }, h("section", { class: "card", "aria-labelledby": "h-verdict" }, - h("h2", { id: "h-verdict", class: "eyebrow" }, "Blended rispetto al mercato"), + h("h2", { id: "h-verdict", class: "eyebrow" }, t("Blended rispetto al mercato")), h("div", { class: `hero ${better > 0 ? "pos" : better < 0 ? "neg" : ""}`, style: { marginTop: "8px" } }, better == null ? "–" : `${better > 0 ? "−" : "+"}${fmt.pct(Math.abs(better))}`), h("p", { class: "secondary", style: { marginTop: "6px" } }, - better == null ? "Dati insufficienti." - : better > 0 ? "di errore rispetto al prezzo di mercato: le previsioni blended sono state più accurate." - : "di errore rispetto al prezzo di mercato: il prezzo è stato più accurato delle previsioni."), - h("p", { class: "muted small", style: { marginTop: "10px" } }, `Su ${fmt.int(cal.resolved_markets)} mercati risolti, usando l'ultima previsione fatta per ciascuno.`), + better == null ? t("Dati insufficienti.") + : better > 0 ? t("di errore rispetto al prezzo di mercato: le previsioni blended sono state più accurate.") + : t("di errore rispetto al prezzo di mercato: il prezzo è stato più accurato delle previsioni.")), + h("p", { class: "muted small", style: { marginTop: "10px" } }, t("Su {0} mercati risolti, usando l'ultima previsione fatta per ciascuno.", fmt.int(cal.resolved_markets))), interval ? h("p", { class: "secondary small", style: { marginTop: "6px" } }, interval) : null, - small ? h("p", { class: "note", style: { marginTop: "12px" } }, h("span", { class: "badge badge-warning" }, icon("alert"), "Campione piccolo"), " Con meno di 30 mercati il confronto dipende molto dal caso.") : null, + small ? h("p", { class: "note", style: { marginTop: "12px" } }, h("span", { class: "badge badge-warning" }, icon("alert"), t("Campione piccolo")), t(" Con meno di 30 mercati il confronto dipende molto dal caso.")) : null, ), h("section", { class: "card", "aria-labelledby": "h-brier" }, - h("div", { class: "card-head" }, h("h2", { id: "h-brier" }, "Brier score"), h("span", { class: "muted small" }, "più basso è meglio")), + h("div", { class: "card-head" }, h("h2", { id: "h-brier" }, t("Brier score")), h("span", { class: "muted small" }, t("più basso è meglio"))), brierBars([ - { key: "market", label: "Prezzo mercato", value: cal.brier_market, first: true }, - { key: "jev", label: "Stima Jev", value: cal.brier_model }, - { key: "blended", label: "Blended", value: cal.brier_blended }, + { key: "market", label: t("Prezzo mercato"), value: cal.brier_market, first: true }, + { key: "jev", label: t("Stima Jev"), value: cal.brier_model }, + { key: "blended", label: t("Blended"), value: cal.brier_blended }, ]), - h("p", { class: "muted small", style: { marginTop: "16px" } }, "Media di (probabilità − esito)², con esito 1 se il mercato si è risolto SÌ e 0 se NO. Chi dice sempre 50% ottiene 0,25."), + h("p", { class: "muted small", style: { marginTop: "16px" } }, t("Media di (probabilità − esito)², con esito 1 se il mercato si è risolto SÌ e 0 se NO. Chi dice sempre 50% ottiene 0,25.")), ), ), h("section", { class: "card", "aria-labelledby": "h-clv", style: { marginTop: "16px" } }, - h("div", { class: "card-head" }, h("h2", { id: "h-clv" }, "Il prezzo è andato verso i segnali?"), - infoTip("Closing line value: la differenza tra il prezzo di chiusura (l'ultimo prima che il mercato smettesse di scambiare) e il prezzo al momento del segnale, nella direzione consigliata. Chi compra stabilmente sotto la chiusura ha un vantaggio reale, e lo si vede molto prima che i mercati risolti siano abbastanza per il Brier.")), + h("div", { class: "card-head" }, h("h2", { id: "h-clv" }, t("Il prezzo è andato verso i segnali?")), + infoTip(t("Closing line value: la differenza tra il prezzo di chiusura (l'ultimo prima che il mercato smettesse di scambiare) e il prezzo al momento del segnale, nella direzione consigliata. Chi compra stabilmente sotto la chiusura ha un vantaggio reale, e lo si vede molto prima che i mercati risolti siano abbastanza per il Brier."))), sc?.n ? h("div", { class: "kpis" }, - statTile("Movimento medio", fmt.pts(sc.avg), sc.interval?.lo != null ? `95%: da ${fmt.pts(sc.interval.lo)} a ${fmt.pts(sc.interval.hi)}` : "intervallo non disponibile"), - statTile("Segnali a favore", fmt.pct(sc.share_positive), `su ${fmt.count(sc.n, "segnale", "segnali")} in mercati chiusi`)) - : h("p", { class: "muted small" }, "Nessun segnale su mercati già chiusi."), + statTile(t("Movimento medio"), fmt.pts(sc.avg), sc.interval?.lo != null ? t("95%: da {0} a {1}", fmt.pts(sc.interval.lo), fmt.pts(sc.interval.hi)) : t("intervallo non disponibile")), + statTile(t("Segnali a favore"), fmt.pct(sc.share_positive), t("su {0} in mercati chiusi", fmt.count(sc.n, t("segnale"), t("segnali"))))) + : h("p", { class: "muted small" }, t("Nessun segnale su mercati già chiusi.")), ), ); } @@ -782,11 +812,11 @@ async function viewAccount() { f.msg.classList.toggle("field-error", Boolean(text)); f.input.setAttribute("aria-invalid", text ? "true" : "false"); }; - const submit = h("button", { class: "btn btn-primary", type: "submit" }, h("span", { class: "spinner", "aria-hidden": "true" }), "Cambia password"); + const submit = h("button", { class: "btn btn-primary", type: "submit" }, h("span", { class: "spinner", "aria-hidden": "true" }), t("Cambia password")); const form = h("form", { class: "form", novalidate: true }, - field("pw-current", "Password attuale", "current-password"), - field("pw-new", "Nuova password", "new-password", "Almeno 12 caratteri, senza lo username. Una frase lunga è più sicura e più facile da ricordare."), - field("pw-confirm", "Ripeti la nuova password", "new-password"), + field("pw-current", t("Password attuale"), "current-password"), + field("pw-new", t("Nuova password"), "new-password", t("Almeno 12 caratteri, senza lo username. Una frase lunga è più sicura e più facile da ricordare.")), + field("pw-confirm", t("Ripeti la nuova password"), "new-password"), submit, ); form.addEventListener("submit", async (e) => { @@ -795,15 +825,15 @@ async function viewAccount() { Object.keys(fields).forEach((id) => setError(id, "")); let first = null; const fail = (id, text) => { setError(id, text); first = first || id; }; - if (!cur) fail("pw-current", "Inserisci la password attuale."); - if (next.length < 12) fail("pw-new", "La nuova password deve avere almeno 12 caratteri."); - if (next && confirm !== next) fail("pw-confirm", "Le due password non coincidono."); + if (!cur) fail("pw-current", t("Inserisci la password attuale.")); + if (next.length < 12) fail("pw-new", t("La nuova password deve avere almeno 12 caratteri.")); + if (next && confirm !== next) fail("pw-confirm", t("Le due password non coincidono.")); if (first) return fields[first].input.focus(); setBusy(submit, true); try { await api("/auth/password", { method: "POST", body: { current_password: cur, new_password: next } }); form.reset(); - toast("Password aggiornata. Le sessioni aperte su altri dispositivi sono state chiuse."); + toast(t("Password aggiornata. Le sessioni aperte su altri dispositivi sono state chiuse.")); } catch (err) { if (err.status === 400) { setError("pw-current", err.message); fields["pw-current"].input.focus(); } else if (err.status === 422) { setError("pw-new", err.message); fields["pw-new"].input.focus(); } @@ -814,23 +844,23 @@ async function viewAccount() { }); return h("div", {}, - pageHead("Account", "Il tuo profilo e la password di accesso."), + pageHead(t("Account"), t("Il tuo profilo e la password di accesso.")), h("div", { class: "grid-2" }, h("section", { class: "card", "aria-labelledby": "h-profile", style: { alignSelf: "start" } }, - h("h2", { id: "h-profile" }, "Profilo"), + h("h2", { id: "h-profile" }, t("Profilo")), h("dl", { class: "dl" }, - h("dt", {}, "Username"), h("dd", { class: "mono" }, me.username), - h("dt", {}, "Ruolo"), h("dd", {}, isAdmin() - ? h("span", { class: "badge badge-outline" }, "Amministratore") - : h("span", { class: "badge badge-outline" }, "Sola lettura")), - h("dt", {}, "Permessi"), h("dd", { class: "secondary" }, isAdmin() - ? "Consulta i dati, aggiorna notizie e mercati, chiede previsioni a Jev." - : "Consulta notizie, mercati, previsioni e calibrazione."), + h("dt", {}, t("Username")), h("dd", { class: "mono" }, me.username), + h("dt", {}, t("Ruolo")), h("dd", {}, isAdmin() + ? h("span", { class: "badge badge-outline" }, t("Amministratore")) + : h("span", { class: "badge badge-outline" }, t("Sola lettura"))), + h("dt", {}, t("Permessi")), h("dd", { class: "secondary" }, isAdmin() + ? t("Consulta i dati, aggiorna notizie e mercati, chiede previsioni a Jev.") + : t("Consulta notizie, mercati, previsioni e calibrazione.")), ), - h("button", { class: "btn btn-ghost", type: "button", style: { marginTop: "16px" }, on: { click: logout } }, icon("logout"), "Esci"), + h("button", { class: "btn btn-ghost", type: "button", style: { marginTop: "16px" }, on: { click: logout } }, icon("logout"), t("Esci")), ), h("section", { class: "card", "aria-labelledby": "h-password" }, - h("h2", { id: "h-password", style: { marginBottom: "12px" } }, "Cambia password"), + h("h2", { id: "h-password", style: { marginBottom: "12px" } }, t("Cambia password")), form, ), ), diff --git a/frontend/boot-check.js b/frontend/boot-check.js index 952e9b3..5b0a111 100644 --- a/frontend/boot-check.js +++ b/frontend/boot-check.js @@ -8,21 +8,27 @@ shown = true; var view = document.getElementById("view"); if (!view) return; + var en = false; + try { en = (localStorage.getItem("lang") || (navigator.language || "")).toLowerCase().indexOf("it") !== 0; } catch (e) { en = false; } var box = document.createElement("div"); box.className = "card empty boot-error"; box.setAttribute("role", "alert"); var title = document.createElement("h3"); - title.textContent = "L'interfaccia non si è avviata"; + title.textContent = en ? "The interface did not start" : "L'interfaccia non si è avviata"; var p1 = document.createElement("p"); - p1.textContent = "Di solito succede dopo un aggiornamento: il browser usa file vecchi dalla cache. " + - "Ricarica la pagina forzando l'aggiornamento (Ctrl+F5, oppure Cmd+Shift+R su Mac)."; + p1.textContent = en + ? "This usually happens after an update: the browser is using old cached files. Reload the page bypassing the cache (Ctrl+F5, or Cmd+Shift+R on a Mac)." + : "Di solito succede dopo un aggiornamento: il browser usa file vecchi dalla cache. " + + "Ricarica la pagina forzando l'aggiornamento (Ctrl+F5, oppure Cmd+Shift+R su Mac)."; var p2 = document.createElement("p"); p2.className = "muted small"; - p2.textContent = "Se il problema resta, il server potrebbe essere bloccato: controlla i log del container."; + p2.textContent = en + ? "If it keeps happening, the server may be stuck: check the container logs." + : "Se il problema resta, il server potrebbe essere bloccato: controlla i log del container."; var btn = document.createElement("button"); btn.className = "btn btn-primary"; btn.type = "button"; - btn.textContent = "Ricarica"; + btn.textContent = en ? "Reload" : "Ricarica"; btn.addEventListener("click", function () { window.location.reload(); }); box.append(title, p1, p2, btn); view.replaceChildren(box); diff --git a/frontend/charts.js b/frontend/charts.js index 7dc5145..0d43e5b 100644 --- a/frontend/charts.js +++ b/frontend/charts.js @@ -2,6 +2,7 @@ // blended = blue (the probability behind the signal), market price = orange, raw Jev = aqua. // Identity never relies on colour alone: every chart has a legend, marker shapes differ // (circle / diamond), the blended line is dashed and every chart has a table or value labels. +import { t, locale } from "./i18n.js"; import { h, fmt, withTooltip, ttRows, showTooltip, hideTooltip } from "./ui.js"; const SVG_NS = "http://www.w3.org/2000/svg"; @@ -18,19 +19,19 @@ const clamp01 = (v) => Math.max(0, Math.min(1, v)); /** Legend with the current values: the visible label channel for every series. */ export function probLegend({ market, blended, jev }) { return h("div", { class: "legend" }, - h("span", {}, h("span", { class: "key market" }), "Prezzo mercato ", h("b", {}, fmt.cents(market))), - blended != null ? h("span", {}, h("span", { class: "key blended" }), "Blended ", h("b", {}, fmt.pct(blended))) : null, - jev != null ? h("span", {}, h("span", { class: "key jev" }), "Stima Jev ", h("b", {}, fmt.pct(jev))) : null, + h("span", {}, h("span", { class: "key market" }), t("Prezzo mercato "), h("b", {}, fmt.cents(market))), + blended != null ? h("span", {}, h("span", { class: "key blended" }), t("Blended "), h("b", {}, fmt.pct(blended))) : null, + jev != null ? h("span", {}, h("span", { class: "key jev" }), t("Stima Jev "), h("b", {}, fmt.pct(jev))) : null, ); } /** 0–100 % track comparing market price, blended probability and raw Jev estimate. */ export function probTrack({ market, blended, jev }) { const marks = []; - const tip = () => ttRows("Probabilità del SÌ", [ - ["market", "Prezzo mercato", fmt.cents(market)], - ...(blended != null ? [["blended", "Blended", fmt.pct(blended)]] : []), - ...(jev != null ? [["jev", "Stima Jev", fmt.pct(jev)]] : []), + const tip = () => ttRows(t("Probabilità del SÌ"), [ + ["market", t("Prezzo mercato"), fmt.cents(market)], + ...(blended != null ? [["blended", t("Blended"), fmt.pct(blended)]] : []), + ...(jev != null ? [["jev", t("Stima Jev"), fmt.pct(jev)]] : []), ]); const mark = (cls, value, label) => { if (value == null) return null; @@ -44,15 +45,15 @@ export function probTrack({ market, blended, jev }) { edge = h("span", { class: "ptrack-edge", style: { left: `${lo * 100}%`, width: `${(hi - lo) * 100}%` } }); } return h("div", { class: "ptrack" }, - h("div", { class: "ptrack-bar", role: "img", "aria-label": `Prezzo ${fmt.pct(market)}${blended != null ? `, blended ${fmt.pct(blended)}` : ""}${jev != null ? `, Jev ${fmt.pct(jev)}` : ""}` }, + h("div", { class: "ptrack-bar", role: "img", "aria-label": t("Prezzo {0}{1}{2}", fmt.pct(market), blended != null ? t(", blended {0}", fmt.pct(blended)) : "", jev != null ? t(", Jev {0}", fmt.pct(jev)) : "") }, h("div", { class: "ptrack-line" }), - h("div", { class: "ptrack-ticks" }, [0, 25, 50, 75, 100].map((t) => h("span", { style: { left: `${t}%` } }))), + h("div", { class: "ptrack-ticks" }, [0, 25, 50, 75, 100].map((x) => h("span", { style: { left: `${x}%` } }))), edge, - mark("jev", jev, "Stima Jev"), - mark("market", market, "Prezzo mercato"), - mark("blended", blended, "Blended"), + mark("jev", jev, t("Stima Jev")), + mark("market", market, t("Prezzo mercato")), + mark("blended", blended, t("Blended")), ), - h("div", { class: "ptrack-axis", "aria-hidden": "true" }, ["0%", "25%", "50%", "75%", "100%"].map((t) => h("span", {}, t))), + h("div", { class: "ptrack-axis", "aria-hidden": "true" }, ["0%", "25%", "50%", "75%", "100%"].map((x) => h("span", {}, x))), ); } @@ -67,17 +68,17 @@ export function historyChart(predictions) { const wrap = h("div", { class: "chart" }); const legend = h("div", { class: "chart-legend" }, - h("span", {}, h("span", { class: "key line market" }), "Prezzo mercato"), - h("span", {}, h("span", { class: "key line blended" }), "Blended"), - h("span", {}, h("span", { class: "key jev" }), "Stima Jev"), + h("span", {}, h("span", { class: "key line market" }), t("Prezzo mercato")), + h("span", {}, h("span", { class: "key line blended" }), t("Blended")), + h("span", {}, h("span", { class: "key jev" }), t("Stima Jev")), ); const holder = h("div", {}); const table = historyTable(points); table.hidden = true; - const toggle = h("button", { class: "link-btn", type: "button", "aria-expanded": "false" }, "Mostra tabella"); + const toggle = h("button", { class: "link-btn", type: "button", "aria-expanded": "false" }, t("Mostra tabella")); toggle.addEventListener("click", () => { table.hidden = !table.hidden; - toggle.textContent = table.hidden ? "Mostra tabella" : "Nascondi tabella"; + toggle.textContent = table.hidden ? t("Mostra tabella") : t("Nascondi tabella"); toggle.setAttribute("aria-expanded", String(!table.hidden)); }); wrap.append(legend, holder, toggle, table); @@ -91,7 +92,7 @@ export function historyChart(predictions) { const x = (t) => m.l + (t1 === t0 ? iw / 2 : ((t - t0) / (t1 - t0)) * iw); const y = (v) => m.t + (1 - clamp01(v)) * ih; - const svg = s("svg", { viewBox: `0 0 ${width} ${height}`, height, role: "img", "aria-label": `Storico di ${points.length} previsioni: prezzo di mercato e probabilità blended` }); + const svg = s("svg", { viewBox: `0 0 ${width} ${height}`, height, role: "img", "aria-label": t("Storico di {0} previsioni: prezzo di mercato e probabilità blended", points.length) }); for (const v of [0, 0.25, 0.5, 0.75, 1]) { svg.append(s("line", { class: v === 0 ? "baseline" : "gridline", x1: m.l, x2: m.l + iw, y1: y(v), y2: y(v) })); svg.append(s("text", { class: "tick", x: m.l - 8, y: y(v) + 4, "text-anchor": "end" }, `${v * 100}%`)); @@ -133,9 +134,9 @@ export function historyChart(predictions) { cross.setAttribute("x2", x(best.t)); cross.setAttribute("visibility", "visible"); showTooltip(ttRows(fmt.dateTime(best.t), [ - ["market", "Prezzo mercato", fmt.cents(best.market)], - ["blended", "Blended", fmt.pct(best.blended)], - ["jev", "Stima Jev", fmt.pct(best.jev)], + ["market", t("Prezzo mercato"), fmt.cents(best.market)], + ["blended", t("Blended"), fmt.pct(best.blended)], + ["jev", t("Stima Jev"), fmt.pct(best.jev)], ]), e.clientX, e.clientY); }); overlay.addEventListener("mouseleave", () => { cross.setAttribute("visibility", "hidden"); hideTooltip(); }); @@ -159,8 +160,8 @@ function historyTable(points) { return h("div", { class: "table-wrap", style: { marginTop: "8px" } }, h("table", {}, h("thead", {}, h("tr", {}, - h("th", {}, "Data"), h("th", { class: "num" }, "Prezzo"), h("th", { class: "num" }, "Jev"), - h("th", { class: "num" }, "Blended"), h("th", {}, "Segnale"), + h("th", {}, t("Data")), h("th", { class: "num" }, t("Prezzo")), h("th", { class: "num" }, t("Jev")), + h("th", { class: "num" }, t("Blended")), h("th", {}, t("Segnale")), )), h("tbody", {}, [...points].reverse().map((p) => h("tr", {}, h("td", {}, fmt.dateTime(p.t)), h("td", { class: "num" }, fmt.cents(p.market)), h("td", { class: "num" }, fmt.pct(p.jev)), @@ -186,7 +187,7 @@ export function brierBars(rows) { h("span", { class: "bar-value", style: { left: `calc(${w}% + 8px)` } }, valueLabel), r.first ? [ h("div", { class: "bar-ref", style: { left: `${(ref / max) * 100}%` } }), - h("span", { class: "bar-ref-label", style: { left: `${(ref / max) * 100}%` } }, "0,25 = sempre 50%"), + h("span", { class: "bar-ref-label", style: { left: `${(ref / max) * 100}%` } }, t("0,25 = sempre 50%")), ] : h("div", { class: "bar-ref", style: { left: `${(ref / max) * 100}%` } }), ), ); @@ -215,11 +216,11 @@ export function equityChart(points, initial) { const y = (v) => m.t + (1 - (v - lo) / (hi - lo)) * ih; const svg = s("svg", { viewBox: `0 0 ${width} ${height}`, height, role: "img", - "aria-label": `Capitale da ${fmt.money(data[0].v)} a ${fmt.money(data[data.length - 1].v)}` }); + "aria-label": t("Capitale da {0} a {1}", fmt.money(data[0].v), fmt.money(data[data.length - 1].v)) }); const ticks = [lo + (hi - lo) * 0.1, (lo + hi) / 2, hi - (hi - lo) * 0.1]; for (const v of ticks) { svg.append(s("line", { class: "gridline", x1: m.l, x2: m.l + iw, y1: y(v), y2: y(v) })); - svg.append(s("text", { class: "tick", x: m.l - 8, y: y(v) + 4, "text-anchor": "end" }, Math.round(v).toLocaleString("it-IT"))); + svg.append(s("text", { class: "tick", x: m.l - 8, y: y(v) + 4, "text-anchor": "end" }, Math.round(v).toLocaleString(locale))); } svg.append(s("line", { x1: m.l, x2: m.l + iw, y1: y(initial), y2: y(initial), stroke: "var(--axis)", "stroke-dasharray": "4 4" })); svg.append(s("text", { class: "tick", x: m.l + iw + 8, y: y(initial) + 4 }, "iniziale")); @@ -230,7 +231,7 @@ export function equityChart(points, initial) { } const last = data[data.length - 1]; svg.append(s("circle", { cx: x(last.t), cy: y(last.v), r: 4.5, fill: "var(--series-blended)", stroke: "var(--surface)", "stroke-width": 2 })); - svg.append(s("text", { class: "endvalue", x: x(last.t) + 10, y: y(last.v) + 4 }, `${Math.round(last.v).toLocaleString("it-IT")} $`)); + svg.append(s("text", { class: "endvalue", x: x(last.t) + 10, y: y(last.v) + 4 }, fmt.dollars(Math.round(last.v).toLocaleString(locale)))); [t0, t1].forEach((t, i) => svg.append(s("text", { class: "tick", x: x(t), y: height - 6, "text-anchor": i ? "end" : "start" }, fmt.date(t)))); const cross = s("line", { class: "crosshair", y1: m.t, y2: m.t + ih, visibility: "hidden" }); @@ -242,7 +243,7 @@ export function equityChart(points, initial) { let best = data[0]; for (const d of data) if (Math.abs(x(d.t) - px) < Math.abs(x(best.t) - px)) best = d; cross.setAttribute("x1", x(best.t)); cross.setAttribute("x2", x(best.t)); cross.setAttribute("visibility", "visible"); - showTooltip(ttRows(fmt.dateTime(best.t), [["blended", "Capitale", fmt.money(best.v)], [null, "Da inizio", fmt.signedMoney(best.v - initial)]]), e.clientX, e.clientY); + showTooltip(ttRows(fmt.dateTime(best.t), [["blended", t("Capitale"), fmt.money(best.v)], [null, t("Da inizio"), fmt.signedMoney(best.v - initial)]]), e.clientX, e.clientY); }); overlay.addEventListener("mouseleave", () => { cross.setAttribute("visibility", "hidden"); hideTooltip(); }); svg.append(overlay); @@ -266,9 +267,9 @@ export function equityChart(points, initial) { */ export function reliabilityChart(calibration) { const series = [ - ["market", "Prezzo mercato", calibration.market, "circle", "var(--series-market)", null, 1.5], - ["jev", "Stima Jev", calibration.model, "diamond", "var(--series-jev)", null, 2], - ["blended", "Blended", calibration.blended, "circle", "var(--series-blended)", "5 4", 2], + ["market", t("Prezzo mercato"), calibration.market, "circle", "var(--series-market)", null, 1.5], + ["jev", t("Stima Jev"), calibration.model, "diamond", "var(--series-jev)", null, 2], + ["blended", t("Blended"), calibration.blended, "circle", "var(--series-blended)", "5 4", 2], ]; const maxN = Math.max(1, ...series.flatMap(([, , bins]) => bins.map((b) => b.n))); const wrap = h("div", { class: "chart reliability" }); @@ -283,15 +284,15 @@ export function reliabilityChart(calibration) { const x = (v) => m.l + clamp01(v) * iw; const y = (v) => m.t + (1 - clamp01(v)) * ih; const svg = s("svg", { viewBox: `0 0 ${width} ${height}`, width, height, role: "img", - "aria-label": "Calibrazione: probabilità prevista contro frequenza reale del SÌ" }); + "aria-label": t("Calibrazione: probabilità prevista contro frequenza reale del SÌ") }); for (const v of [0, 0.25, 0.5, 0.75, 1]) { svg.append(s("line", { class: "gridline", x1: m.l, x2: m.l + iw, y1: y(v), y2: y(v) })); svg.append(s("text", { class: "tick", x: m.l - 8, y: y(v) + 4, "text-anchor": "end" }, `${v * 100}%`)); svg.append(s("text", { class: "tick", x: x(v), y: m.t + ih + 16, "text-anchor": "middle" }, `${v * 100}%`)); } - svg.append(s("text", { class: "tick", x: m.l + iw / 2, y: height - 2, "text-anchor": "middle" }, "probabilità prevista")); + svg.append(s("text", { class: "tick", x: m.l + iw / 2, y: height - 2, "text-anchor": "middle" }, t("probabilità prevista"))); svg.append(s("line", { x1: x(0), y1: y(0), x2: x(1), y2: y(1), stroke: "var(--axis)", "stroke-dasharray": "3 4" })); - svg.append(s("text", { class: "tick", x: x(0.97), y: y(0.97) + 16, "text-anchor": "end" }, "calibrazione perfetta")); + svg.append(s("text", { class: "tick", x: x(0.97), y: y(0.97) + 16, "text-anchor": "end" }, t("calibrazione perfetta"))); for (const [key, label, bins, shape, color, dash, sw] of series) { if (!bins.length) continue; @@ -304,8 +305,8 @@ export function reliabilityChart(calibration) { ? s("path", { d: `M${cx},${cy - r - 1}L${cx + r + 1},${cy}L${cx},${cy + r + 1}L${cx - r - 1},${cy}Z`, fill: color, stroke: "var(--surface)", "stroke-width": 1.5 }) : s("circle", { cx, cy, r, fill: key === "market" ? "var(--surface)" : color, stroke: color, "stroke-width": 2 }); mark.setAttribute("tabindex", "0"); - mark.setAttribute("aria-label", `${label}: previsto ${fmt.pct(b.mean_forecast)}, accaduto ${fmt.pct(b.frequency)} su ${b.n} casi`); - const tip = (e) => showTooltip(ttRows(label, [[key, "Previsto in media", fmt.pct(b.mean_forecast)], [null, "SÌ accaduto", fmt.pct(b.frequency)], [null, "Casi", fmt.int(b.n)]]), e.clientX, e.clientY); + mark.setAttribute("aria-label", t("{0}: previsto {1}, accaduto {2} su {3} casi", label, fmt.pct(b.mean_forecast), fmt.pct(b.frequency), b.n)); + const tip = (e) => showTooltip(ttRows(label, [[key, t("Previsto in media"), fmt.pct(b.mean_forecast)], [null, t("SÌ accaduto"), fmt.pct(b.frequency)], [null, t("Casi"), fmt.int(b.n)]]), e.clientX, e.clientY); mark.addEventListener("mousemove", tip); mark.addEventListener("mouseleave", hideTooltip); mark.addEventListener("focus", () => { const rc = mark.getBoundingClientRect(); tip({ clientX: rc.right, clientY: rc.top }); }); @@ -325,17 +326,17 @@ export function reliabilityChart(calibration) { } const legend = h("div", { class: "legend" }, - h("span", {}, h("span", { class: "key market" }), "Prezzo mercato"), - h("span", {}, h("span", { class: "key jev" }), "Stima Jev"), - h("span", {}, h("span", { class: "key blended" }), "Blended"), - h("span", { class: "muted small" }, "punti più grandi = più casi")); + h("span", {}, h("span", { class: "key market" }), t("Prezzo mercato")), + h("span", {}, h("span", { class: "key jev" }), t("Stima Jev")), + h("span", {}, h("span", { class: "key blended" }), t("Blended")), + h("span", { class: "muted small" }, t("punti più grandi = più casi"))); const rows = []; series.forEach(([key, label, bins]) => bins.forEach((b) => rows.push(h("tr", {}, h("td", {}, label), h("td", { class: "num" }, `${Math.round(b.lo * 100)}–${Math.round(b.hi * 100)}%`), h("td", { class: "num" }, fmt.pct(b.mean_forecast)), h("td", { class: "num" }, fmt.pct(b.frequency)), h("td", { class: "num" }, fmt.int(b.n)))))); - const table = h("details", { class: "chart-table" }, h("summary", {}, "Mostra tabella"), + const table = h("details", { class: "chart-table" }, h("summary", {}, t("Mostra tabella")), h("div", { class: "table-wrap" }, h("table", { class: "compact-table" }, - h("thead", {}, h("tr", {}, ...["Serie", "Fascia", "Previsto", "Accaduto", "Casi"].map((t, i) => h("th", { scope: "col", class: i ? "num" : null }, t)))), + h("thead", {}, h("tr", {}, ...[t("Serie"), t("Fascia"), t("Previsto"), t("Accaduto"), t("Casi")].map((t, i) => h("th", { scope: "col", class: i ? "num" : null }, t)))), h("tbody", {}, rows)))); return h("div", {}, legend, wrap, table); } @@ -378,7 +379,7 @@ export function dailyBars(days, { format, label }) { } const hit = s("rect", { x: m.l + i * step, y: m.t, width: step, height: ih, fill: "transparent", tabindex: "0", "aria-label": `${fmt.date(d.day)}: ${format(d.value)}` }); - const show = (cx, cy) => showTooltip(ttRows(fmt.date(d.day), [[null, "Totale", format(d.value)], ...d.tip]), cx, cy); + const show = (cx, cy) => showTooltip(ttRows(fmt.date(d.day), [[null, t("Totale"), format(d.value)], ...d.tip]), cx, cy); hit.addEventListener("mousemove", (e) => show(e.clientX, e.clientY)); hit.addEventListener("mouseleave", hideTooltip); hit.addEventListener("focus", () => { const rc = hit.getBoundingClientRect(); show(rc.right, rc.top); }); diff --git a/frontend/economics.js b/frontend/economics.js index 037c995..fcfc3b8 100644 --- a/frontend/economics.js +++ b/frontend/economics.js @@ -1,19 +1,20 @@ // "Conviene?" card: live economic evaluation of the latest forecast of a market. +import { t } from "./i18n.js"; import { h, api, fmt, toast, icon, infoTip } from "./ui.js"; import { strategySection } from "./strategy.js"; export const VERDICTS = { - GO: { cls: "badge-good", icon: "check", label: "Conviene" }, - SMALL: { cls: "badge-warning", icon: "alert", label: "Conviene poco" }, - NO: { cls: "badge-critical", icon: "x", label: "Non conviene" }, + GO: { cls: "badge-good", icon: "check", label: t("Conviene") }, + SMALL: { cls: "badge-warning", icon: "alert", label: t("Conviene poco") }, + NO: { cls: "badge-critical", icon: "x", label: t("Non conviene") }, }; export const ECON_HELP = { - stake: "Cifra da investire in quote, calcolata con il criterio di Kelly sul book reale e poi ridotta dai limiti del preset.", - limit: "Prezzo massimo per quota oltre il quale la scommessa non conviene più: usalo come prezzo limite dell'ordine.", - apr: "Rendimento atteso riportato su base annua con la probabilità prudente: confronta scommesse con scadenze diverse.", - prudent: "Probabilità del lato scelto meno l'incertezza della stima (z × σ). Le decisioni usano questa, non quella centrale.", - breakeven: "Probabilità minima di vittoria perché la scommessa vada in pari, commissioni comprese.", + stake: t("Cifra da investire in quote, calcolata con il criterio di Kelly sul book reale e poi ridotta dai limiti del preset."), + limit: t("Prezzo massimo per quota oltre il quale la scommessa non conviene più: usalo come prezzo limite dell'ordine."), + apr: t("Rendimento atteso riportato su base annua con la probabilità prudente: confronta scommesse con scadenze diverse."), + prudent: t("Probabilità del lato scelto meno l'incertezza della stima (z × σ). Le decisioni usano questa, non quella centrale."), + breakeven: t("Probabilità minima di vittoria perché la scommessa vada in pari, commissioni comprese."), }; export function verdictBadge(verdict, big = false) { @@ -33,22 +34,23 @@ function figure(label, value, help, cls = "") { function aprText(apr) { if (apr == null) return "–"; - return apr >= 10 ? "oltre 1000%" : pct(apr); + return apr >= 10 ? t("oltre 1000%") : pct(apr); } const CAP_LABELS = { - market: "Per mercato", event: "Per evento", category: "Per categoria", total: "Totale investito", cash: "Liquidità disponibile", book: "Profondità del book", + market: t("Per mercato"), event: t("Per evento"), category: t("Per categoria"), total: t("Totale investito"), cash: t("Liquidità disponibile"), book: t("Profondità del book"), }; export function economicsCard(ctx, market) { const body = h("div", { class: "econ-body" }, h("div", { class: "skeleton", style: { height: "140px" } })); let preset = null; - const presetSwitch = h("div", { class: "segmented", role: "group", "aria-label": "Preset di rischio" }); + const PRESET_LABELS = { prudente: t("Prudente"), bilanciato: t("Bilanciato"), aggressivo: t("Aggressivo") }; + const presetSwitch = h("div", { class: "segmented", role: "group", "aria-label": t("Preset di rischio") }); const paintSwitch = (active) => presetSwitch.replaceChildren(...["prudente", "bilanciato", "aggressivo"].map((key) => h("button", { type: "button", class: "seg", "aria-pressed": String(key === active), on: { click: () => { preset = key; paintSwitch(key); load(); } }, - }, key[0].toUpperCase() + key.slice(1)))); + }, PRESET_LABELS[key]))); async function load() { body.setAttribute("aria-busy", "true"); @@ -60,10 +62,10 @@ export function economicsCard(ctx, market) { } catch (e) { if (!presetSwitch.childElementCount) paintSwitch(preset); body.replaceChildren(e.status === 409 - ? h("p", { class: "secondary" }, "Serve prima una previsione di Jev.") + ? h("p", { class: "secondary" }, t("Serve prima una previsione di Jev.")) : h("div", { class: "econ-error", role: "alert" }, - h("p", { class: "secondary" }, `Valutazione non disponibile: ${e.message}`), - h("button", { class: "btn btn-ghost btn-sm", type: "button", on: { click: load } }, icon("refresh"), "Riprova"))); + h("p", { class: "secondary" }, t("Valutazione non disponibile: {0}", e.message)), + h("button", { class: "btn btn-ghost btn-sm", type: "button", on: { click: load } }, icon("refresh"), t("Riprova")))); } finally { body.removeAttribute("aria-busy"); body.classList.remove("is-loading"); @@ -74,23 +76,23 @@ export function economicsCard(ctx, market) { const ev = data.evaluation; const blocking = ev.reasons.filter((r) => r.blocking); const infoReasons = ev.reasons.filter((r) => !r.blocking); - const side = ev.side === "YES" ? "SÌ" : "NO"; + const side = ev.side === "YES" ? t("SÌ") : "NO"; const isGo = ev.verdict !== "NO"; // With a position already open, a full market cap means "hold what you have", not "bad bet" const holding = data.open_bet && blocking.every((r) => r.code === "exposure_cap"); const b = data.open_bet; const headline = holding - ? `Hai già ${fmt.shares(b.shares)} quote ${b.side === "YES" ? "SÌ" : "NO"} (${money(b.outlay)}): il limite del preset per questo mercato è raggiunto, non conviene aggiungere.` + ? t("Hai già {0} quote {1} ({2}): il limite del preset per questo mercato è raggiunto, non conviene aggiungere.", fmt.shares(b.shares), b.side === "YES" ? t("SÌ") : "NO", money(b.outlay)) : isGo - ? `${data.open_bet ? "Puoi aggiungere" : "Compra"} ${fmt.shares(ev.shares)} quote ${side} per ${money(ev.outlay)}, a non più di ${fmt.cents(ev.limit_price)} l'una.` - : "Con il preset scelto questa scommessa non conviene."; + ? t("{0} {1} quote {2} per {3}, a non più di {4} l'una.", data.open_bet ? t("Puoi aggiungere") : t("Compra"), fmt.shares(ev.shares), side, money(ev.outlay), fmt.cents(ev.limit_price)) + : t("Con il preset scelto questa scommessa non conviene."); return h("div", {}, data.strategy ? strategySection(data.strategy, data.market.yes_price) : null, - data.strategy ? h("h3", { class: "econ-sub" }, "I conti della scommessa") : null, + data.strategy ? h("h3", { class: "econ-sub" }, t("I conti della scommessa")) : null, h("div", { class: "econ-head" }, - holding ? h("span", { class: "badge badge-accent badge-lg" }, icon("check"), "In portafoglio") : verdictBadge(ev.verdict, true), + holding ? h("span", { class: "badge badge-accent badge-lg" }, icon("check"), t("In portafoglio")) : verdictBadge(ev.verdict, true), h("p", { class: "econ-headline" }, headline), ), blocking.length && !holding ? h("ul", { class: "reasons" }, blocking.map((r) => h("li", {}, icon("x"), h("span", {}, r.text)))) : null, @@ -98,45 +100,45 @@ export function economicsCard(ctx, market) { ev.notes.map((n) => h("p", { class: "note", style: { marginTop: "8px" } }, icon("alert"), n)), h("div", { class: "econ-grid" }, - figure("Puntata", isGo ? money(ev.outlay) : "–", ECON_HELP.stake), - figure("Prezzo medio", ev.avg_price != null ? fmt.cents(ev.avg_price) : ev.best_price != null ? `${fmt.cents(ev.best_price)} (migliore)` : "–"), + figure(t("Puntata"), isGo ? money(ev.outlay) : "–", ECON_HELP.stake), + figure(t("Prezzo medio"), ev.avg_price != null ? fmt.cents(ev.avg_price) : ev.best_price != null ? t("{0} (migliore)", fmt.cents(ev.best_price)) : "–"), // Without a signal the plan's price levels apply, not this maximum - figure("Prezzo massimo", ev.reasons.some((r) => r.code === "no_signal") ? "–" : fmt.cents(ev.limit_price), ECON_HELP.limit), - figure("Se vince", isGo ? fmt.signedMoney(ev.profit_if_win) : "–", null, "pos"), - figure("Se perde", isGo ? fmt.signedMoney(-ev.outlay) : "–", null, "neg"), - figure("Profitto atteso", isGo ? fmt.signedMoney(ev.expected_profit) : "–", null, ev.expected_profit > 0 ? "pos" : ""), - figure("Rendimento annuo (prudente)", aprText(ev.apr), ECON_HELP.apr), - figure("Probabilità di perdere", pct(ev.prob_loss)), - figure("Pareggio sopra", ev.break_even != null ? pct(ev.break_even) : "–", ECON_HELP.breakeven), + figure(t("Prezzo massimo"), ev.reasons.some((r) => r.code === "no_signal") ? "–" : fmt.cents(ev.limit_price), ECON_HELP.limit), + figure(t("Se vince"), isGo ? fmt.signedMoney(ev.profit_if_win) : "–", null, "pos"), + figure(t("Se perde"), isGo ? fmt.signedMoney(-ev.outlay) : "–", null, "neg"), + figure(t("Profitto atteso"), isGo ? fmt.signedMoney(ev.expected_profit) : "–", null, ev.expected_profit > 0 ? "pos" : ""), + figure(t("Rendimento annuo (prudente)"), aprText(ev.apr), ECON_HELP.apr), + figure(t("Probabilità di perdere"), pct(ev.prob_loss)), + figure(t("Pareggio sopra"), ev.break_even != null ? pct(ev.break_even) : "–", ECON_HELP.breakeven), ), h("details", { class: "econ-details" }, - h("summary", {}, "Come è calcolato"), + h("summary", {}, t("Come è calcolato")), h("ol", { class: "xsteps", style: { marginTop: "12px" } }, h("li", { class: "xstep" }, h("span", { class: "xstep-n" }, "1"), h("div", {}, - h("div", { class: "xstep-title" }, "Probabilità prudente", infoTip(ECON_HELP.prudent)), - h("p", { class: "formula" }, `${pct(ev.p_side)} − ${String(data.preset.z).replace(".", ",")} × ${pct(ev.sigma)} = `, h("b", {}, pct(ev.p_conservative))), - h("p", { class: "muted small" }, `Probabilità che vinca il ${side} secondo la stima blended, meno l'incertezza della stima.`), + h("div", { class: "xstep-title" }, t("Probabilità prudente"), infoTip(ECON_HELP.prudent)), + h("p", { class: "formula" }, t("{0} − {1} × {2} = ", pct(ev.p_side), fmt.dec(data.preset.z), pct(ev.sigma)), h("b", {}, pct(ev.p_conservative))), + h("p", { class: "muted small" }, t("Probabilità che vinca il {0} secondo la stima blended, meno l'incertezza della stima.", side)), )), h("li", { class: "xstep" }, h("span", { class: "xstep-n" }, "2"), h("div", {}, - h("div", { class: "xstep-title" }, "Costi reali"), - h("p", {}, ev.quote_source === "book" ? "Prezzi presi dal book di Polymarket in questo momento." : "Book non disponibile: prezzo stimato."), - h("p", { class: "formula" }, `Prezzo medio ${ev.avg_price != null ? fmt.cents(ev.avg_price) : fmt.cents(ev.best_price)} (a metà mercato ${fmt.cents(ev.mid)}), commissioni ${money(ev.fee)}`), - ev.net_edge != null ? h("p", { class: "formula" }, `Margine netto: ${pct(ev.p_conservative)} − costo per quota = `, h("b", {}, fmt.pts(ev.net_edge)), - ` (minimo ${Math.round(data.preset.min_net_edge * 100)} pt)`) : null, + h("div", { class: "xstep-title" }, t("Costi reali")), + h("p", {}, ev.quote_source === "book" ? t("Prezzi presi dal book di Polymarket in questo momento.") : t("Book non disponibile: prezzo stimato.")), + h("p", { class: "formula" }, t("Prezzo medio {0} (a metà mercato {1}), commissioni {2}", ev.avg_price != null ? fmt.cents(ev.avg_price) : fmt.cents(ev.best_price), fmt.cents(ev.mid), money(ev.fee))), + ev.net_edge != null ? h("p", { class: "formula" }, t("Margine netto: {0} − costo per quota = ", pct(ev.p_conservative)), h("b", {}, fmt.pts(ev.net_edge)), + t(" (minimo {0} pt)", Math.round(data.preset.min_net_edge * 100))) : null, )), h("li", { class: "xstep" }, h("span", { class: "xstep-n" }, "3"), h("div", {}, - h("div", { class: "xstep-title" }, "Tempo"), - h("p", {}, `Il mercato si risolve tra ${Math.round(ev.days)} giorni. Rendimento annuo prudente ${aprText(ev.apr)}; soglia ${pct(ev.hurdle_apr)} (tasso senza rischio + premio del preset).`), + h("div", { class: "xstep-title" }, t("Tempo")), + h("p", {}, t("Il mercato si risolve tra {0} giorni. Rendimento annuo prudente {1}; soglia {2} (tasso senza rischio + premio del preset).", Math.round(ev.days), aprText(ev.apr), pct(ev.hurdle_apr))), )), h("li", { class: "xstep" }, h("span", { class: "xstep-n" }, "4"), h("div", {}, - h("div", { class: "xstep-title" }, "Quanto puntare"), - h("p", { class: "formula" }, `Kelly sul book ${money(ev.kelly_stake)} × ${String(data.preset.kelly_scale).replace(".", ",")} = ${money(ev.target_stake)}`), + h("div", { class: "xstep-title" }, t("Quanto puntare")), + h("p", { class: "formula" }, t("Kelly sul book {0} × {1} = {2}", money(ev.kelly_stake), fmt.dec(data.preset.kelly_scale), money(ev.target_stake))), h("table", { class: "caps" }, h("tbody", {}, Object.entries(ev.caps).map(([k, v]) => h("tr", { class: ev.limited_by === k ? "limiting" : "" }, h("td", {}, CAP_LABELS[k] || k), h("td", { class: "num" }, money(v)), h("td", {}, ev.limited_by === k ? h("span", { class: "badge badge-warning" }, "limita") : ""), )))), - h("p", { class: "muted small" }, "Spazio ancora libero per ciascun limite del preset: la puntata è il minimo tra Kelly e questi valori."), + h("p", { class: "muted small" }, t("Spazio ancora libero per ciascun limite del preset: la puntata è il minimo tra Kelly e questi valori.")), )), ), ), @@ -147,8 +149,8 @@ export function economicsCard(ctx, market) { const section = h("section", { class: "card econ", "aria-labelledby": "h-econ" }, h("div", { class: "card-head" }, h("div", {}, - h("h2", { id: "h-econ" }, "Cosa fare"), - h("p", { class: "muted small" }, "Strategia di acquisto e vendita, con prezzi reali, costi, tempo e limiti di rischio."), + h("h2", { id: "h-econ" }, t("Cosa fare")), + h("p", { class: "muted small" }, t("Strategia di acquisto e vendita, con prezzi reali, costi, tempo e limiti di rischio.")), ), presetSwitch, ), @@ -167,44 +169,44 @@ function portfolioBar(ctx, market, data) { if (data.open_bet) { const b = data.open_bet; - bar.append(h("p", {}, icon("check"), ` Nel portafoglio simulato: ${fmt.shares(b.shares)} quote ${b.side === "YES" ? "SÌ" : "NO"} a ${fmt.cents(b.avg_price)} (${money(b.outlay)}). `, - h("a", { href: "#/portafoglio" }, "Vedi il portafoglio"))); + bar.append(h("p", {}, icon("check"), t(" Nel portafoglio simulato: {0} quote {1} a {2} ({3}). ", fmt.shares(b.shares), b.side === "YES" ? t("SÌ") : "NO", fmt.cents(b.avg_price), money(b.outlay)), + h("a", { href: "#/portafoglio" }, t("Vedi il portafoglio")))); } else if (data.excluded_by) { - const what = { market: "questo mercato", event: "questo evento", category: `la categoria ${data.market.category}` }[data.excluded_by]; - bar.append(h("p", {}, icon("pause"), ` Escluso dal portafoglio simulato automatico: hai escluso ${what}.`)); + const what = { market: t("questo mercato"), event: t("questo evento"), category: t("la categoria {0}", data.market.category) }[data.excluded_by]; + bar.append(h("p", {}, icon("pause"), t(" Escluso dal portafoglio simulato automatico: hai escluso {0}.", what))); } else if (ev.verdict !== "NO") { - bar.append(h("p", {}, "Le prossime previsioni con questo esito vengono aggiunte automaticamente al portafoglio simulato.")); + bar.append(h("p", {}, t("Le prossime previsioni con questo esito vengono aggiunte automaticamente al portafoglio simulato."))); } if (admin) { const actions = h("div", { class: "form-actions" }); if (!data.open_bet && ev.verdict !== "NO") { - const add = h("button", { class: "btn btn-primary btn-sm", type: "button" }, h("span", { class: "spinner", "aria-hidden": "true" }), "Aggiungi ora al portafoglio simulato"); + const add = h("button", { class: "btn btn-primary btn-sm", type: "button" }, h("span", { class: "spinner", "aria-hidden": "true" }), t("Aggiungi ora al portafoglio simulato")); add.addEventListener("click", async () => { add.disabled = true; try { await api(`/markets/${encodeURIComponent(market.id)}/paper-bet`, { method: "POST" }); - toast("Scommessa simulata aggiunta"); + toast(t("Scommessa simulata aggiunta")); refresh(); } catch (e) { toast(e.message, { error: true }); add.disabled = false; } }); actions.append(add); } - const exclusions = [["market", market.id, "Escludi questo mercato", market.question]]; - if (data.market.event_slug) exclusions.push(["event", data.market.event_slug, "Escludi tutto l'evento", data.market.event_slug]); + const exclusions = [["market", market.id, t("Escludi questo mercato"), market.question]]; + if (data.market.event_slug) exclusions.push(["event", data.market.event_slug, t("Escludi tutto l'evento"), data.market.event_slug]); for (const [kind, value, label, desc] of exclusions) { const btn = h("button", { class: "btn btn-ghost btn-sm", type: "button" }, label); btn.addEventListener("click", async () => { btn.disabled = true; try { await api("/portfolio/exclusions", { method: "POST", body: { kind, value, label: desc } }); - toast("Esclusione salvata: le scommesse automatiche lo salteranno. Le scommesse già aperte restano, puoi escluderle dal portafoglio."); + toast(t("Esclusione salvata: le scommesse automatiche lo salteranno. Le scommesse già aperte restano, puoi escluderle dal portafoglio.")); refresh(); } catch (e) { toast(e.message, { error: true }); btn.disabled = false; } }); if (!data.excluded_by) actions.append(btn); } - if (data.excluded_by) actions.append(h("a", { class: "btn btn-ghost btn-sm", href: "#/portafoglio" }, "Gestisci le esclusioni")); + if (data.excluded_by) actions.append(h("a", { class: "btn btn-ghost btn-sm", href: "#/portafoglio" }, t("Gestisci le esclusioni"))); bar.append(actions); } return bar; diff --git a/frontend/explain.js b/frontend/explain.js index 23d3381..b6c22c5 100644 --- a/frontend/explain.js +++ b/frontend/explain.js @@ -1,4 +1,5 @@ // Plain-language explanations of how a forecast becomes a signal, using the real numbers. +import { t } from "./i18n.js"; import { h, fmt, icon, infoTip, GLOSSARY } from "./ui.js"; const pct = (v) => fmt.pct(v); @@ -31,13 +32,13 @@ export function pool(pModel, price, w, method) { export function explainSentence(p, status) { const w = weightOf(p, status); const gap = Math.abs(p.edge) * 100; - const side = p.edge > 0 ? "sopra" : "sotto"; - const verdict = p.signal === "BUY_YES" ? "Il SÌ sembra sottovalutato." - : p.signal === "BUY_NO" ? "Il NO sembra sottovalutato." - : "Differenza troppo piccola o evidenze troppo deboli per un segnale."; - return `Jev stima ${pct(p.model_probability)} contro un prezzo di ${fmt.cents(p.market_probability)}. ` - + `Con evidenze al ${pct(p.evidence_strength)} la stima pesa per il ${pct(w)}: ` - + `probabilità finale ${pct(p.blended_probability)}, ${gap.toFixed(1).replace(".", ",")} punti ${side} il prezzo. ${verdict}`; + const side = p.edge > 0 ? t("sopra") : t("sotto"); + const verdict = p.signal === "BUY_YES" ? t("Il SÌ sembra sottovalutato.") + : p.signal === "BUY_NO" ? t("Il NO sembra sottovalutato.") + : t("Differenza troppo piccola o evidenze troppo deboli per un segnale."); + return t("Jev stima {0} contro un prezzo di {1}. ", pct(p.model_probability), fmt.cents(p.market_probability)) + + t("Con evidenze al {0} la stima pesa per il {1}: ", pct(p.evidence_strength), pct(w)) + + t("probabilità finale {0}, {1} punti {2} il prezzo. {3}", pct(p.blended_probability), fmt.dec(gap, 1), side, verdict); } function check(ok, text) { @@ -77,54 +78,54 @@ export function explainCard(p, market, evidence, status) { return h("section", { class: "card explain", "aria-labelledby": "h-explain" }, h("div", { class: "card-head" }, - h("h2", { id: "h-explain" }, "Perché questo segnale"), - h("a", { href: "#/metodo", class: "small" }, "Come funziona il metodo"), + h("h2", { id: "h-explain" }, t("Perché questo segnale")), + h("a", { href: "#/metodo", class: "small" }, t("Come funziona il metodo")), ), h("p", { class: "secondary", style: { marginBottom: "14px" } }, explainSentence(p, status)), h("ol", { class: "xsteps" }, - step(1, "Stima indipendente di Jev", GLOSSARY.jev, - h("p", {}, `Jev ha letto le regole del mercato e ${p.article_count} notizie collegate, senza vedere il prezzo: `, - h("b", { class: "mono" }, pct(p.model_probability)), " di probabilità che si risolva SÌ."), + step(1, t("Stima indipendente di Jev"), GLOSSARY.jev, + h("p", {}, t("Jev ha letto le regole del mercato e {0} notizie collegate, senza vedere il prezzo: ", p.article_count), + h("b", { class: "mono" }, pct(p.model_probability)), t(" di probabilità che si risolva SÌ.")), judged ? h("p", { class: "muted small" }, - `Delle notizie valutate: ${tally.raises_yes} favoriscono il SÌ, ${tally.lowers_yes} il NO, ${tally.neutral} sono neutre.`) : null, + t("Delle notizie valutate: {0} favoriscono il SÌ, {1} il NO, {2} sono neutre.", tally.raises_yes, tally.lowers_yes, tally.neutral)) : null, ), - step(2, "Quanto contano le notizie", GLOSSARY.weight, - h("p", {}, "Forza delle evidenze ", h("b", { class: "mono" }, pct(p.evidence_strength)), - ". Il peso di Jev è il peso massimo per la forza delle evidenze:"), - formula(`w = ${pct(maxW)} × ${pct(p.evidence_strength)} = `, h("b", {}, pct(w))), + step(2, t("Quanto contano le notizie"), GLOSSARY.weight, + h("p", {}, t("Forza delle evidenze "), h("b", { class: "mono" }, pct(p.evidence_strength)), + t(". Il peso di Jev è il peso massimo per la forza delle evidenze:")), + formula(t("w = {0} × {1} = ", pct(maxW), pct(p.evidence_strength)), h("b", {}, pct(w))), ), - step(3, "Probabilità finale (blended)", GLOSSARY.blended, + step(3, t("Probabilità finale (blended)"), GLOSSARY.blended, cal != null && Math.abs(cal - p.model_probability) >= 0.0005 - ? h("p", {}, "Prima la stima di Jev viene corretta con la calibrazione stimata dal backtest: ", + ? h("p", {}, t("Prima la stima di Jev viene corretta con la calibrazione stimata dal backtest: "), h("b", { class: "mono" }, `${pct(p.model_probability)} → ${pct(cal)}`), ".") : null, - h("p", {}, `Il resto del peso (${pct(1 - w)}) va al prezzo di mercato, che di solito è già ben informato. `, - linear ? "Media pesata delle due probabilità:" : "Le due probabilità si uniscono in log-odds (le quote logaritmiche): una stima netta e ben motivata non viene diluita come in una media semplice."), + h("p", {}, t("Il resto del peso ({0}) va al prezzo di mercato, che di solito è già ben informato. ", pct(1 - w)), + linear ? t("Media pesata delle due probabilità:") : t("Le due probabilità si uniscono in log-odds (le quote logaritmiche): una stima netta e ben motivata non viene diluita come in una media semplice.")), linear - ? formula(`${pct(w)} × ${pct(cal ?? p.model_probability)} + ${pct(1 - w)} × ${pct(q)} = `, h("b", {}, pct(b))) - : formula(`logit(blended) = ${pct(w)} × logit(${pct(cal ?? p.model_probability)}) + ${pct(1 - w)} × logit(${pct(q)}) → `, h("b", {}, pct(b))), + ? formula(t("{0} × {1} + {2} × {3} = ", pct(w), pct(cal ?? p.model_probability), pct(1 - w), pct(q)), h("b", {}, pct(b))) + : formula(t("logit(blended) = {0} × logit({1}) + {2} × logit({3}) → ", pct(w), pct(cal ?? p.model_probability), pct(1 - w), pct(q)), h("b", {}, pct(b))), ), - step(4, "Edge rispetto al prezzo", GLOSSARY.edge, + step(4, t("Edge rispetto al prezzo"), GLOSSARY.edge, formula(`${pct(b)} − ${pct(q)} = `, h("b", { class: p.edge > 0 ? "pos" : p.edge < 0 ? "neg" : "" }, pts(p.edge))), ), - step(5, "Controlli per emettere un segnale", null, + step(5, t("Controlli per emettere un segnale"), null, h("ul", { class: "checks" }, - check(edgeOk, `Edge di almeno ${Math.round(minEdge * 100)} punti (qui ${pts(Math.abs(p.edge)).replace("+", "")})`), - check(evOk, `Forza delle evidenze di almeno ${pct(minEvidence)} (qui ${pct(p.evidence_strength)})`), + check(edgeOk, t("Edge di almeno {0} punti (qui {1})", Math.round(minEdge * 100), pts(Math.abs(p.edge)).replace("+", ""))), + check(evOk, t("Forza delle evidenze di almeno {0} (qui {1})", pct(minEvidence), pct(p.evidence_strength))), ), - h("p", {}, "Risultato: ", h("b", {}, p.signal === "BUY_YES" ? "Compra SÌ" : p.signal === "BUY_NO" ? "Compra NO" : "Attendi"), - p.signal === "HOLD" ? " (almeno un controllo non è superato)." : "."), + h("p", {}, t("Risultato: "), h("b", {}, p.signal === "BUY_YES" ? t("Compra SÌ") : p.signal === "BUY_NO" ? t("Compra NO") : t("Attendi")), + p.signal === "HOLD" ? t(" (almeno un controllo non è superato).") : "."), ), - p.signal !== "HOLD" ? step(6, "Puntata suggerita", GLOSSARY.kelly, + p.signal !== "HOLD" ? step(6, t("Puntata suggerita"), GLOSSARY.kelly, buyYes - ? formula(`Kelly = (${pct(b)} − ${pct(q)}) / (100% − ${pct(q)}) = ${pct(fullKelly)}`) - : formula(`Kelly = (${pct(q)} − ${pct(b)}) / ${pct(q)} = ${pct(fullKelly)}`), - formula(`× ${String(kellyScale).replace(".", ",")} (Kelly frazionario) = `, h("b", {}, `${pct(p.kelly_fraction)} del capitale`)), + ? formula(t("Kelly = ({0} − {1}) / (100% − {2}) = {3}", pct(b), pct(q), pct(q), pct(fullKelly))) + : formula(t("Kelly = ({0} − {1}) / {2} = {3}", pct(q), pct(b), pct(q), pct(fullKelly))), + formula(t("× {0} (Kelly frazionario) = ", fmt.dec(kellyScale)), h("b", {}, t("{0} del capitale", pct(p.kelly_fraction)))), h("p", { class: "muted small" }, buyYes - ? `Compri quote SÌ a ${fmt.cents(q)}: se il mercato si risolve SÌ ogni quota vale 1 $.` - : `Compri quote NO a ${fmt.cents(1 - q)}: se il mercato si risolve NO ogni quota vale 1 $.`), + ? t("Compri quote SÌ a {0}: se il mercato si risolve SÌ ogni quota vale 1 $.", fmt.cents(q)) + : t("Compri quote NO a {0}: se il mercato si risolve NO ogni quota vale 1 $.", fmt.cents(1 - q))), ) : null, ), h("p", { class: "note", style: { marginTop: "14px" } }, icon("alert"), - "Il prezzo usato è quello al momento della previsione. Prima di agire controlla il prezzo attuale su Polymarket e le commissioni: l'edge le deve coprire."), + t("Il prezzo usato è quello al momento della previsione. Prima di agire controlla il prezzo attuale su Polymarket e le commissioni: l'edge le deve coprire.")), ); } diff --git a/frontend/i18n-en.js b/frontend/i18n-en.js new file mode 100644 index 0000000..f404e29 --- /dev/null +++ b/frontend/i18n-en.js @@ -0,0 +1,1171 @@ +// English UI texts, keyed by the Italian source string passed to t() (see i18n.js). +// A missing key falls back to the Italian text. +export default { + "Mostra password": "Show password", + "Nascondi password": "Hide password", + "Accedi": "Sign in", + "Username": "Username", + "Password": "Password", + "Inserisci username e password.": "Enter username and password.", + "Dashboard privata di News × Markets.": "Private News × Markets dashboard.", + "Non hai un account? Chiedi a chi gestisce il server di crearlo con ": "No account? Ask whoever runs the server to create one with ", + "Password corretta, ma il browser non ha salvato la sessione: il cookie è riservato alle connessioni HTTPS ": "Password correct, but the browser did not save the session: the cookie is reserved for HTTPS connections ", + "e stai usando HTTP da un indirizzo di rete. Su una rete di casa fidata imposta SESSION_COOKIE_SECURE=false ": "and you are using HTTP from a network address. On a trusted home network set SESSION_COOKIE_SECURE=false ", + "nel file .env e riavvia; altrimenti usa HTTPS (per esempio con Cloudflare Tunnel).": "in the .env file and restart; otherwise use HTTPS (for example with Cloudflare Tunnel).", + "Password corretta, ma il browser non ha salvato la sessione. Controlla che i cookie non siano bloccati per questo sito.": "Password correct, but the browser did not save the session. Check that cookies are not blocked for this site.", + "Sei uscito. A presto.": "You are signed out. See you soon.", + "Il server non risponde. Controlla che l'API sia avviata e che il database sia raggiungibile.": "The server is not responding. Check that the API is running and the database is reachable.", + "Previsioni disattivate: manca la chiave TypeSafe. Aggiungi ": "Forecasts disabled: the TypeSafe key is missing. Add ", + " al file .env e riavvia. Nel frattempo le notizie vengono classificate con un'euristica locale.": " to the .env file and restart. Meanwhile news is classified with a local heuristic.", + "Limite giornaliero delle API AI raggiunto: previsioni, allerte e backtest sono in pausa fino a mezzanotte. ": "Daily AI API limit reached: forecasts, alerts and backtests are paused until midnight. ", + "Vedi uso e costi": "See usage and costs", + "Server non raggiungibile": "Server unreachable", + "Ultima notizia {0}": "Latest news {0}", + "Nessuna notizia ancora": "No news yet", + "{0} mercati aperti": "{0} open markets", + "attivo": "on", + "non configurato": "not configured", + "Jev {0}": "Jev {0}", + "previsioni automatiche": "automatic forecasts", + "previsioni manuali": "manual forecasts", + "La sessione è scaduta. Accedi di nuovo.": "Your session has expired. Sign in again.", + "Aggiornamento notizie avviato. La pagina si aggiorna da sola.": "News update started. The page refreshes by itself.", + "Aggiornamento mercati avviato. La pagina si aggiorna da sola.": "Market update started. The page refreshes by itself.", + "Passa al tema scuro": "Switch to dark theme", + "Passa al tema chiaro": "Switch to light theme", + "Vai al contenuto": "Skip to content", + "Menu principale": "Main menu", + "News × Markets, vai alle opportunità": "News × Markets, go to opportunities", + "Sezioni": "Sections", + "Aggiorna": "Update", + "Aggiorna notizie": "Update news", + "Scarica le fonti, classifica e collega ai mercati": "Fetch the sources, classify and link to markets", + "Aggiorna mercati": "Update markets", + "Prezzi da Polymarket e collegamento delle notizie": "Prices from Polymarket and news linking", + "Segnali indicativi, non consulenza finanziaria. L'app non piazza ordini su Polymarket.": "Indicative signals, not financial advice. The app does not place orders on Polymarket.", + "Sezioni principali": "Main sections", + "Tutte le sezioni": "All sections", + "Chiudi": "Close", + "Lingua": "Language", + "Notizie classificate con TypeSafe Jev e previsioni sui mercati Polymarket": "News classified with TypeSafe Jev and forecasts on Polymarket markets", + "Impossibile caricare la pagina": "Could not load the page", + "Riprova": "Retry", + "Mercati a più esiti": "Multi-outcome markets", + "Tutti gli eventi": "All events", + "Mercati aperti": "Open markets", + "binari Sì/No su Polymarket": "Yes/No binaries on Polymarket", + "Con notizie collegate": "With linked news", + "notizie recenti simili alla domanda": "recent news similar to the question", + "Previsioni Jev": "Jev forecasts", + "ultima {0}": "latest {0}", + "nessuna ancora": "none yet", + "Segnali attivi": "Active signals", + "edge ≥ {0} pt": "edge ≥ {0} pts", + "Impossibile caricare le opportunità": "Could not load the opportunities", + "Filtri": "Filters", + "Edge minimo": "Minimum edge", + "Evidenze minime": "Minimum evidence", + "Mostra anche «Attendi»": "Also show «Wait»", + "Opportunità": "Opportunities", + "Mercati in cui la stima di Jev, pesata per la forza delle notizie, si discosta dal prezzo. Ordinati per edge.": "Markets where Jev's estimate, weighted by the strength of the news, departs from the price. Sorted by edge.", + "Come funziona": "How it works", + "Nessun dato": "No data", + "Il server non risponde.": "The server is not responding.", + "Nessun mercato ancora": "No markets yet", + "Scarica i mercati Sì/No più scambiati da Polymarket per iniziare.": "Fetch the most traded Yes/No markets from Polymarket to get started.", + "Un amministratore deve scaricare i mercati da Polymarket.": "An administrator needs to fetch the markets from Polymarket.", + "Nessuna notizia collegata ai mercati": "No news linked to markets", + "Le notizie recenti non somigliano ancora a nessuna domanda di mercato. Aggiorna le notizie o abbassa MARKET_MATCH_THRESHOLD nel file .env.": "Recent news does not resemble any market question yet. Update the news or lower MARKET_MATCH_THRESHOLD in the .env file.", + "Previsioni non disponibili": "Forecasts unavailable", + "Configura TYPESAFE_API_KEY per ottenere le stime di Jev sui mercati con notizie collegate.": "Set TYPESAFE_API_KEY to get Jev's estimates on markets with linked news.", + "Nessuna previsione ancora": "No forecasts yet", + "{0} mercati hanno notizie collegate. Apri un mercato e chiedi una previsione a Jev.": "{0} markets have linked news. Open a market and ask Jev for a forecast.", + "Vedi i mercati con notizie": "See markets with news", + "Nessuna opportunità con questi filtri": "No opportunities with these filters", + "Abbassa l'edge o le evidenze minime, oppure mostra anche i mercati in attesa.": "Lower the edge or the minimum evidence, or also show markets on hold.", + "Scade {0}": "Ends {0}", + "Volume {0}": "Volume {0}", + "{0} notizie analizzate": "{0} news items analysed", + "Previsione {0}": "Forecast {0}", + "Vedi il calcolo": "See the calculation", + "Polymarket ": "Polymarket ", + "Edge": "Edge", + "Conviene?": "Worth it?", + "Valutazione economica al momento della previsione: prezzo reale dal book, costi, incertezza, tempo e limiti del preset.": "Economic assessment at the time of the forecast: real price from the book, costs, uncertainty, time and preset limits.", + "Puntata {0}": "Stake {0}", + "Puntata suggerita": "Suggested stake", + "del bankroll": "of bankroll", + "Forza delle evidenze": "Evidence strength", + "Volume": "Volume", + "dal più basso": "lowest first", + "dal più alto": "highest first", + "Scadenza": "End date", + "prima i più vicini": "soonest first", + "prima i più lontani": "latest first", + "Prezzo SÌ": "YES price", + "Ultimo segnale": "Latest signal", + "prima i meno recenti": "oldest first", + "prima i più recenti": "newest first", + "dal più piccolo": "smallest first", + "dal più grande": "largest first", + "Notizie collegate": "Linked news", + "prima le meno": "fewest first", + "prima le più": "most first", + "Liquidità": "Liquidity", + "dalla più bassa": "lowest first", + "dalla più alta": "highest first", + "Nome": "Name", + "A → Z": "A → Z", + "Z → A": "Z → A", + "mercato": "market", + "mercati": "markets", + "{0} di {1}, ordinati per {2} ({3})": "{0} of {1}, sorted by {2} ({3})", + "Carica altri": "Load more", + "Mercato": "Market", + "Notizie": "News", + "Ordinato per {0}, {1}. Clic per invertire": "Sorted by {0}, {1}. Click to reverse", + "Ordina per {0}": "Sort by {0}", + "Direzione: {0}. Clic per invertire": "Direction: {0}. Click to reverse", + "Ordina per": "Sort by", + "Cerca un mercato (es. Fed, elezioni, Bitcoin)": "Search a market (e.g. Fed, elections, Bitcoin)", + "Cerca un mercato": "Search a market", + "Nessun mercato trovato": "No markets found", + "Nessun mercato con notizie collegate corrisponde alla ricerca. Togli il filtro «Solo con notizie» per vederli tutti.": "No market with linked news matches the search. Turn off the «With news only» filter to see them all.", + "Aggiorna i mercati da Polymarket o cambia la ricerca.": "Update the markets from Polymarket or change the search.", + "Mercati": "Markets", + "I mercati Sì/No più scambiati su Polymarket. Il prezzo in centesimi è la probabilità implicita del SÌ.": "The most traded Yes/No markets on Polymarket. The price in cents is the implied probability of YES.", + "Filtri e ordinamento": "Filters and sorting", + "Solo con notizie": "With news only", + "Includi chiusi": "Include closed", + "scaduto": "expired", + "oggi": "today", + "domani": "tomorrow", + "tra {0} gg": "in {0} d", + "Nessuna previsione": "No forecast", + "Nuova previsione": "New forecast", + "Chiedi una previsione a Jev": "Ask Jev for a forecast", + "Solo gli amministratori possono chiedere previsioni (sono chiamate a pagamento).": "Only administrators can request forecasts (they are paid calls).", + "Serve TYPESAFE_API_KEY nel file .env.": "TYPESAFE_API_KEY is needed in the .env file.", + "Il mercato è chiuso.": "The market is closed.", + "Nessuna notizia recente collegata a questo mercato.": "No recent news linked to this market.", + "Compra SÌ": "Buy YES", + "Compra NO": "Buy NO", + "Attendi": "Wait", + "Previsione salvata: {0} (edge {1})": "Forecast saved: {0} (edge {1})", + "Ultima previsione": "Latest forecast", + "Puntata": "Stake", + "Jev": "Jev", + "Calcolata {0} con {1}. Il prezzo usato è quello di quel momento.": "Computed {0} with {1}. The price used is the one at that moment.", + "Nessuna previsione per questo mercato. Jev legge le regole del mercato e le notizie collegate e stima la probabilità del SÌ, senza vedere il prezzo.": "No forecast for this market. Jev reads the market rules and the linked news and estimates the probability of YES, without seeing the price.", + "Una chiamata all'API TypeSafe. Il prezzo viene aggiornato prima.": "One call to the TypeSafe API. The price is refreshed first.", + "Storico delle previsioni": "Forecast history", + "{0} previsioni": "{0} forecasts", + "Lo storico compare dopo la prima previsione.": "The history appears after the first forecast.", + "Cerca su Google News le notizie delle ultime ore su questo mercato": "Search Google News for the last hours' news on this market", + "Cerca notizie": "Search news", + "notizia nuova": "new news item", + "notizie nuove": "new news items", + "{0} trovate con «{1}».": "{0} found with «{1}».", + "Nessuna notizia nuova per «{0}».": "No new news for «{0}».", + "Una per storia, dalla più utile. Le prime {0} vengono lette da Jev.": "One per story, most useful first. The first {0} are read by Jev.", + "Pertinenza: somiglianza di significato tra notizia e domanda, più la presenza dei termini chiave (nomi, sigle, numeri). Utilità: pertinenza × affidabilità della fonte × freschezza × giudizio di Jev. Rilevanza e impatto: il giudizio di Jev dopo l'ultima previsione.": "Match: similarity of meaning between the news and the question, plus the presence of key terms (names, acronyms, numbers). Usefulness: match × source reliability × freshness × Jev's judgement. Relevance and impact: Jev's judgement after the latest forecast.", + "Come sono ordinate?": "How are they sorted?", + "Fonti diverse che hanno riportato la stessa notizia": "Different sources that reported the same news", + "{0} fonti": "{0} sources", + "Trovata dalla ricerca mirata per questo mercato": "Found by the targeted search for this market", + "ricerca mirata": "targeted search", + "Termini chiave trovati": "Key terms found", + "Pertinenza ": "Match ", + "Rilevanza ": "Relevance ", + "Nessuna notizia recente riguarda questo mercato.": "No recent news concerns this market.", + "Tutti i mercati": "All markets", + "Mercato Polymarket": "Polymarket market", + "Prezzo SÌ ": "YES price ", + "Liquidità {0}": "Liquidity {0}", + "Apri su Polymarket": "Open on Polymarket", + "Regole di risoluzione": "Resolution rules", + "Calibrazione": "Calibration", + "Quanto sono state accurate le previsioni sui mercati già risolti, rispetto al prezzo di mercato.": "How accurate the forecasts on already resolved markets were, compared with the market price.", + "Ancora nessun mercato risolto": "No resolved market yet", + "Quando un mercato con almeno una previsione si chiude, qui trovi il confronto tra Jev, blended e prezzo. Servono decine di mercati risolti prima di fidarsi dei segnali.": "When a market with at least one forecast closes, here you find the comparison between Jev, blended and price. You need dozens of resolved markets before trusting the signals.", + "Anche nel caso peggiore dell'intervallo al 95% il blended batte il prezzo: il vantaggio non sembra dovuto al caso.": "Even in the worst case of the 95% interval the blend beats the price: the advantage does not look like chance.", + "Anche nel caso migliore dell'intervallo al 95% il prezzo fa meglio del blended.": "Even in the best case of the 95% interval the price does better than the blend.", + "L'intervallo al 95% comprende lo zero: con questi mercati non si può ancora dire chi sia più accurato.": "The 95% interval includes zero: with these markets you cannot yet say which is more accurate.", + " (vantaggio in Brier {0}, da {1} a {2})": " (Brier advantage {0}, from {1} to {2})", + "Blended rispetto al mercato": "Blended versus the market", + "Dati insufficienti.": "Not enough data.", + "di errore rispetto al prezzo di mercato: le previsioni blended sono state più accurate.": "of error compared with the market price: the blended forecasts were more accurate.", + "di errore rispetto al prezzo di mercato: il prezzo è stato più accurato delle previsioni.": "of error compared with the market price: the price was more accurate than the forecasts.", + "Su {0} mercati risolti, usando l'ultima previsione fatta per ciascuno.": "Over {0} resolved markets, using the latest forecast made for each.", + "Campione piccolo": "Small sample", + " Con meno di 30 mercati il confronto dipende molto dal caso.": " With fewer than 30 markets the comparison depends a lot on chance.", + "Brier score": "Brier score", + "più basso è meglio": "lower is better", + "Prezzo mercato": "Market price", + "Stima Jev": "Jev estimate", + "Blended": "Blended", + "Media di (probabilità − esito)², con esito 1 se il mercato si è risolto SÌ e 0 se NO. Chi dice sempre 50% ottiene 0,25.": "Mean of (probability − outcome)², with outcome 1 if the market resolved YES and 0 if NO. Always saying 50% scores 0.25.", + "Il prezzo è andato verso i segnali?": "Did the price move towards the signals?", + "Closing line value: la differenza tra il prezzo di chiusura (l'ultimo prima che il mercato smettesse di scambiare) e il prezzo al momento del segnale, nella direzione consigliata. Chi compra stabilmente sotto la chiusura ha un vantaggio reale, e lo si vede molto prima che i mercati risolti siano abbastanza per il Brier.": "Closing line value: the difference between the closing price (the last one before the market stopped trading) and the price at the time of the signal, in the recommended direction. Consistently buying below the close is a real edge, and it shows long before there are enough resolved markets for the Brier score.", + "Movimento medio": "Average move", + "95%: da {0} a {1}": "95%: from {0} to {1}", + "intervallo non disponibile": "interval not available", + "Segnali a favore": "Signals in favour", + "segnale": "signal", + "segnali": "signals", + "su {0} in mercati chiusi": "of {0} on closed markets", + "Nessun segnale su mercati già chiusi.": "No signals on markets already closed.", + "Cambia password": "Change password", + "Password attuale": "Current password", + "Nuova password": "New password", + "Almeno 12 caratteri, senza lo username. Una frase lunga è più sicura e più facile da ricordare.": "At least 12 characters, without the username. A long phrase is safer and easier to remember.", + "Ripeti la nuova password": "Repeat the new password", + "Inserisci la password attuale.": "Enter the current password.", + "La nuova password deve avere almeno 12 caratteri.": "The new password must be at least 12 characters long.", + "Le due password non coincidono.": "The two passwords do not match.", + "Password aggiornata. Le sessioni aperte su altri dispositivi sono state chiuse.": "Password updated. Sessions open on other devices have been closed.", + "Account": "Account", + "Il tuo profilo e la password di accesso.": "Your profile and sign-in password.", + "Profilo": "Profile", + "Ruolo": "Role", + "Amministratore": "Administrator", + "Sola lettura": "Read only", + "Permessi": "Permissions", + "Consulta i dati, aggiorna notizie e mercati, chiede previsioni a Jev.": "Views the data, updates news and markets, asks Jev for forecasts.", + "Consulta notizie, mercati, previsioni e calibrazione.": "Views news, markets, forecasts and calibration.", + "Esci": "Sign out", + "Prezzo mercato ": "Market price ", + "Blended ": "Blended ", + "Stima Jev ": "Jev estimate ", + "Probabilità del SÌ": "Probability of YES", + ", blended {0}": ", blended {0}", + ", Jev {0}": ", Jev {0}", + "Prezzo {0}{1}{2}": "Price {0}{1}{2}", + "Mostra tabella": "Show table", + "Nascondi tabella": "Hide table", + "Storico di {0} previsioni: prezzo di mercato e probabilità blended": "History of {0} forecasts: market price and blended probability", + "Data": "Date", + "Prezzo": "Price", + "Segnale": "Signal", + "0,25 = sempre 50%": "0.25 = always 50%", + "Capitale da {0} a {1}": "Capital from {0} to {1}", + "Capitale": "Capital", + "Da inizio": "Since start", + "Calibrazione: probabilità prevista contro frequenza reale del SÌ": "Calibration: forecast probability versus the real frequency of YES", + "probabilità prevista": "forecast probability", + "calibrazione perfetta": "perfect calibration", + "{0}: previsto {1}, accaduto {2} su {3} casi": "{0}: forecast {1}, happened {2} over {3} cases", + "Previsto in media": "Average forecast", + "SÌ accaduto": "YES happened", + "Casi": "Cases", + "punti più grandi = più casi": "bigger dots = more cases", + "Serie": "Series", + "Fascia": "Band", + "Previsto": "Forecast", + "Accaduto": "Happened", + "Totale": "Total", + "Conviene": "Worth it", + "Conviene poco": "Barely worth it", + "Non conviene": "Not worth it", + "Cifra da investire in quote, calcolata con il criterio di Kelly sul book reale e poi ridotta dai limiti del preset.": "Amount to invest in shares, computed with the Kelly criterion on the real book and then reduced by the preset limits.", + "Prezzo massimo per quota oltre il quale la scommessa non conviene più: usalo come prezzo limite dell'ordine.": "Maximum price per share above which the bet is no longer worth it: use it as the order's limit price.", + "Rendimento atteso riportato su base annua con la probabilità prudente: confronta scommesse con scadenze diverse.": "Expected return on an annual basis with the prudent probability: compares bets with different end dates.", + "Probabilità del lato scelto meno l'incertezza della stima (z × σ). Le decisioni usano questa, non quella centrale.": "Probability of the chosen side minus the uncertainty of the estimate (z × σ). Decisions use this one, not the central one.", + "Probabilità minima di vittoria perché la scommessa vada in pari, commissioni comprese.": "Minimum probability of winning for the bet to break even, fees included.", + "oltre 1000%": "over 1000%", + "Per mercato": "Per market", + "Per evento": "Per event", + "Per categoria": "Per category", + "Totale investito": "Total invested", + "Liquidità disponibile": "Available cash", + "Profondità del book": "Book depth", + "Preset di rischio": "Risk preset", + "Serve prima una previsione di Jev.": "A Jev forecast is needed first.", + "Valutazione non disponibile: {0}": "Assessment not available: {0}", + "SÌ": "YES", + "Hai già {0} quote {1} ({2}): il limite del preset per questo mercato è raggiunto, non conviene aggiungere.": "You already hold {0} {1} shares ({2}): the preset limit for this market has been reached, adding is not worth it.", + "Puoi aggiungere": "You can add", + "Compra": "Buy", + "{0} {1} quote {2} per {3}, a non più di {4} l'una.": "{0} {1} {2} shares for {3}, at no more than {4} each.", + "Con il preset scelto questa scommessa non conviene.": "With the chosen preset this bet is not worth it.", + "I conti della scommessa": "The numbers of the bet", + "In portafoglio": "In portfolio", + "Prezzo medio": "Average price", + "{0} (migliore)": "{0} (best)", + "Prezzo massimo": "Maximum price", + "Se vince": "If it wins", + "Se perde": "If it loses", + "Profitto atteso": "Expected profit", + "Rendimento annuo (prudente)": "Annual return (prudent)", + "Probabilità di perdere": "Probability of losing", + "Pareggio sopra": "Break-even above", + "Come è calcolato": "How it is computed", + "Probabilità prudente": "Prudent probability", + "{0} − {1} × {2} = ": "{0} − {1} × {2} = ", + "Probabilità che vinca il {0} secondo la stima blended, meno l'incertezza della stima.": "Probability that {0} wins according to the blended estimate, minus the uncertainty of the estimate.", + "Costi reali": "Real costs", + "Prezzi presi dal book di Polymarket in questo momento.": "Prices taken from the Polymarket book right now.", + "Book non disponibile: prezzo stimato.": "Book not available: estimated price.", + "Prezzo medio {0} (a metà mercato {1}), commissioni {2}": "Average price {0} (mid market {1}), fees {2}", + "Margine netto: {0} − costo per quota = ": "Net margin: {0} − cost per share = ", + " (minimo {0} pt)": " (minimum {0} pts)", + "Tempo": "Time", + "Il mercato si risolve tra {0} giorni. Rendimento annuo prudente {1}; soglia {2} (tasso senza rischio + premio del preset).": "The market resolves in {0} days. Prudent annual return {1}; threshold {2} (risk-free rate + preset premium).", + "Quanto puntare": "How much to stake", + "Kelly sul book {0} × {1} = {2}": "Kelly on the book {0} × {1} = {2}", + "Spazio ancora libero per ciascun limite del preset: la puntata è il minimo tra Kelly e questi valori.": "Room still free under each preset limit: the stake is the minimum between Kelly and these values.", + "Cosa fare": "What to do", + "Strategia di acquisto e vendita, con prezzi reali, costi, tempo e limiti di rischio.": "Buy and sell strategy, with real prices, costs, time and risk limits.", + " Nel portafoglio simulato: {0} quote {1} a {2} ({3}). ": " In the simulated portfolio: {0} {1} shares at {2} ({3}). ", + "Vedi il portafoglio": "See the portfolio", + "questo mercato": "this market", + "questo evento": "this event", + "la categoria {0}": "the category {0}", + " Escluso dal portafoglio simulato automatico: hai escluso {0}.": " Excluded from the automatic simulated portfolio: you excluded {0}.", + "Le prossime previsioni con questo esito vengono aggiunte automaticamente al portafoglio simulato.": "The next forecasts with this outcome are added automatically to the simulated portfolio.", + "Aggiungi ora al portafoglio simulato": "Add to the simulated portfolio now", + "Scommessa simulata aggiunta": "Simulated bet added", + "Escludi questo mercato": "Exclude this market", + "Escludi tutto l'evento": "Exclude the whole event", + "Esclusione salvata: le scommesse automatiche lo salteranno. Le scommesse già aperte restano, puoi escluderle dal portafoglio.": "Exclusion saved: automatic bets will skip it. Bets already open stay, you can exclude them from the portfolio.", + "Gestisci le esclusioni": "Manage exclusions", + "sopra": "above", + "sotto": "below", + "Il SÌ sembra sottovalutato.": "YES looks undervalued.", + "Il NO sembra sottovalutato.": "NO looks undervalued.", + "Differenza troppo piccola o evidenze troppo deboli per un segnale.": "Difference too small or evidence too weak for a signal.", + "Jev stima {0} contro un prezzo di {1}. ": "Jev estimates {0} against a price of {1}. ", + "Con evidenze al {0} la stima pesa per il {1}: ": "With evidence at {0} the estimate weighs {1}: ", + "probabilità finale {0}, {1} punti {2} il prezzo. {3}": "final probability {0}, {1} points {2} the price. {3}", + "Perché questo segnale": "Why this signal", + "Come funziona il metodo": "How the method works", + "Stima indipendente di Jev": "Jev's independent estimate", + "Jev ha letto le regole del mercato e {0} notizie collegate, senza vedere il prezzo: ": "Jev read the market rules and {0} linked news items, without seeing the price: ", + " di probabilità che si risolva SÌ.": " probability that it resolves YES.", + "Delle notizie valutate: {0} favoriscono il SÌ, {1} il NO, {2} sono neutre.": "Of the news assessed: {0} favour YES, {1} NO, {2} are neutral.", + "Quanto contano le notizie": "How much the news counts", + "Forza delle evidenze ": "Evidence strength ", + ". Il peso di Jev è il peso massimo per la forza delle evidenze:": ". Jev's weight is the maximum weight times the evidence strength:", + "w = {0} × {1} = ": "w = {0} × {1} = ", + "Probabilità finale (blended)": "Final probability (blended)", + "Prima la stima di Jev viene corretta con la calibrazione stimata dal backtest: ": "First Jev's estimate is corrected with the calibration estimated by the backtest: ", + "Il resto del peso ({0}) va al prezzo di mercato, che di solito è già ben informato. ": "The rest of the weight ({0}) goes to the market price, which is usually already well informed. ", + "Media pesata delle due probabilità:": "Weighted average of the two probabilities:", + "Le due probabilità si uniscono in log-odds (le quote logaritmiche): una stima netta e ben motivata non viene diluita come in una media semplice.": "The two probabilities are combined in log-odds: a sharp, well-grounded estimate is not diluted as in a simple average.", + "{0} × {1} + {2} × {3} = ": "{0} × {1} + {2} × {3} = ", + "logit(blended) = {0} × logit({1}) + {2} × logit({3}) → ": "logit(blended) = {0} × logit({1}) + {2} × logit({3}) → ", + "Edge rispetto al prezzo": "Edge over the price", + "Controlli per emettere un segnale": "Checks to issue a signal", + "Edge di almeno {0} punti (qui {1})": "Edge of at least {0} points (here {1})", + "Forza delle evidenze di almeno {0} (qui {1})": "Evidence strength of at least {0} (here {1})", + "Risultato: ": "Result: ", + " (almeno un controllo non è superato).": " (at least one check is not passed).", + "Kelly = ({0} − {1}) / (100% − {2}) = {3}": "Kelly = ({0} − {1}) / (100% − {2}) = {3}", + "Kelly = ({0} − {1}) / {2} = {3}": "Kelly = ({0} − {1}) / {2} = {3}", + "× {0} (Kelly frazionario) = ": "× {0} (fractional Kelly) = ", + "{0} del capitale": "{0} of capital", + "Compri quote SÌ a {0}: se il mercato si risolve SÌ ogni quota vale 1 $.": "You buy YES shares at {0}: if the market resolves YES each share is worth $1.", + "Compri quote NO a {0}: se il mercato si risolve NO ogni quota vale 1 $.": "You buy NO shares at {0}: if the market resolves NO each share is worth $1.", + "Il prezzo usato è quello al momento della previsione. Prima di agire controlla il prezzo attuale su Polymarket e le commissioni: l'edge le deve coprire.": "The price used is the one at the time of the forecast. Before acting check the current price on Polymarket and the fees: the edge must cover them.", + "Segnali": "Signals", + "Allerte": "Alerts", + "Sì / No": "Yes / No", + "Più esiti": "Multi-outcome", + "Risultati": "Results", + "Portafoglio": "Portfolio", + "Backtest": "Backtest", + "Impostazioni": "Settings", + "Uso e costi": "Usage and costs", + "Altro": "More", + "{0} nuove": "{0} new", + "Espandi il menu": "Expand the menu", + "Riduci il menu": "Collapse the menu", + "Aspetta": "Wait", + "Evita": "Avoid", + "Tieni": "Hold", + "Vendi": "Sell", + "Nessuna azione": "No action", + "Il prezzo a cui incassare subito rende più che aspettare la risoluzione, tenendo conto delle commissioni e del rendimento che il preset chiede al capitale bloccato.": "The price at which cashing in now pays more than waiting for resolution, taking into account fees and the return the preset asks of locked-up capital.", + "Prezzi del SÌ a cui la decisione cambierebbe. La stima viene ricalcolata a ogni prezzo con lo stesso metodo della previsione.": "YES prices at which the decision would change. The estimate is recomputed at each price with the same method as the forecast.", + "Niente stop-loss fisso: se il prezzo scende ma la stima non cambia, la quota è ancora più conveniente. Si vende quando una nuova previsione gira la stima o il prezzo raggiunge il valore stimato.": "No fixed stop-loss: if the price falls but the estimate does not change, the share is even better value. You sell when a new forecast turns the estimate or the price reaches the estimated value.", + "{0} quote ": "{0} shares ", + "Ordine in attesa: ": "Pending order: ", + "compra {0} a {1} o meno": "buy {0} at {1} or less", + ". Conviene solo se il prezzo arriva lì.": ". Worth it only if the price gets there.", + "Acquisto con ordine limite: ": "Buy with a limit order: ", + "{0}{1} a non più di {2}": "{0}{1} at no more than {2}", + " · circa {0}": " · about {0}", + "Appena comprate: ": "Once bought: ", + "vendita limite a {0}": "limit sell at {0}", + " (resta nel book finché il prezzo non ci arriva)": " (stays in the book until the price gets there)", + "Vendita: ": "Sell: ", + "{0}{1} a {2} o più": "{0}{1} at {2} or more", + "{0}{1} al meglio": "{0}{1} at market", + "compra SÌ sotto {0}": "buy YES below {0}", + "compra NO sopra {0}": "buy NO above {0}", + "vendi a {0}": "sell at {0}", + "Prezzo del SÌ {0}: {1}": "YES price {0}: {1}", + "vendi {0}": "sell {0}", + "ora {0}": "now {0}", + "prezzo del SÌ": "YES price", + "Compra SÌ sotto {0}": "Buy YES below {0}", + "Compra NO se il SÌ sale sopra {0}": "Buy NO if YES rises above {0}", + "lato comprato": "side bought", + "Vendi a {0} ({1})": "Sell at {0} ({1})", + "Nessun motivo forte a favore.": "No strong reason in favour.", + "Nessun motivo contro rilevante.": "No relevant reason against.", + "fiducia {0}": "{0} confidence", + "Perché sì": "Why yes", + "Perché no": "Why not", + "Piano d'uscita": "Exit plan", + "Fiducia {0}: {1}.": "Confidence: {0} ({1}).", + "{0} a non più di {1} · circa {2}": "{0} at no more than {1} · about {2}", + "al prezzo di allora costi e incertezza si mangiavano il vantaggio (limite {0})": "at the price back then costs and uncertainty ate up the edge (limit {0})", + "adesso": "just now", + "Impossibile contattare il server. Controlla la connessione e riprova.": "Could not reach the server. Check the connection and try again.", + "Errore {0}": "Error {0}", + "Favorisce SÌ": "Favours YES", + "Favorisce NO": "Favours NO", + "Neutra": "Neutral", + "Non ancora valutata": "Not assessed yet", + "Risolto SÌ": "Resolved YES", + "Risolto NO": "Resolved NO", + "Chiuso": "Closed", + "Aperto": "Open", + "valore": "value", + "Politica": "Politics", + "Economia": "Economy", + "Crypto": "Crypto", + "Tecnologia": "Technology", + "Esteri": "World", + "Scienza e salute": "Science and health", + "Sport": "Sport", + "Cultura": "Culture", + "Nord America": "North America", + "Europa": "Europe", + "Medio Oriente e Africa": "Middle East and Africa", + "America Latina": "Latin America", + "Globale": "Global", + "Prezzo di una quota SÌ su Polymarket, in centesimi. Una quota paga 1 $ se l'evento accade: 35¢ equivale a una probabilità implicita del 35%.": "Price of a YES share on Polymarket, in cents. A share pays $1 if the event happens: 35¢ means an implied probability of 35%.", + "Probabilità che il mercato si risolva SÌ secondo Jev, calcolata leggendo le regole del mercato e le notizie collegate. Jev non vede il prezzo.": "Probability that the market resolves YES according to Jev, computed by reading the market rules and the linked news. Jev does not see the price.", + "Quanto le notizie collegate dicono qualcosa di concreto sull'esito, da 0% (nulla di rilevante) a 100% (informazione decisiva). Stabilisce quanto pesa la stima di Jev.": "How much the linked news says something concrete about the outcome, from 0% (nothing relevant) to 100% (decisive information). It sets how much Jev's estimate weighs.", + "Peso della stima di Jev nella probabilità finale: peso massimo × forza delle evidenze. Il resto del peso va al prezzo di mercato.": "Weight of Jev's estimate in the final probability: maximum weight × evidence strength. The rest of the weight goes to the market price.", + "Probabilità finale usata per il segnale: una media pesata tra la stima di Jev e il prezzo di mercato.": "Final probability used for the signal: a weighted average of Jev's estimate and the market price.", + "Differenza tra la probabilità blended e il prezzo, in punti percentuali. Positivo: il SÌ sembra sottovalutato. Negativo: il NO sembra sottovalutato.": "Difference between the blended probability and the price, in percentage points. Positive: YES looks undervalued. Negative: NO looks undervalued.", + "Quota del capitale da puntare secondo il criterio di Kelly, ridotta per prudenza (Kelly frazionario). È un'indicazione, non un obbligo.": "Share of capital to stake according to the Kelly criterion, reduced for prudence (fractional Kelly). It is a suggestion, not an obligation.", + "Errore quadratico medio tra probabilità prevista ed esito reale (1 se SÌ, 0 se NO). Più basso è meglio; dire sempre 50% vale 0,25.": "Mean squared error between forecast probability and actual outcome (1 if YES, 0 if NO). Lower is better; always saying 50% scores 0.25.", + "Quanto la notizia può cambiare la probabilità di un evento futuro verificabile su cui si scommette (elezioni, tassi, conflitti, sentenze, prezzi, partite).": "How much the news can change the probability of a verifiable future event people bet on (elections, rates, conflicts, rulings, prices, matches).", + "Che cos'è?": "What is this?", + "15 min": "15 min", + "1 ora": "1 hour", + "6 ore": "6 hours", + "24 ore": "24 hours", + "Impossibile caricare le allerte": "Could not load the alerts", + "Quali allerte": "Which alerts", + "Solo opportunità": "Opportunities only", + "Tutte le valutazioni": "All assessments", + "Controlla subito le notizie collegate dall'ultimo controllo": "Check now the news linked since the last check", + "Controlla ora": "Check now", + "mercato valutato": "market assessed", + "mercati valutati": "markets assessed", + "opportunità": "opportunities", + "Notizie trovate, ma i mercati sono in pausa o il limite giornaliero è raggiunto.": "News found, but markets are paused or the daily limit has been reached.", + "Nessuna notizia nuova da valutare.": "No new news to assess.", + "Quando esce una notizia importante per un mercato, Jev lo valuta subito. Se conviene arriva una notifica su Telegram; poi si misura se il prezzo si è mosso nella direzione prevista.": "When important news comes out for a market, Jev assesses it right away. If it is worth it a Telegram notification arrives; then it measures whether the price moved in the expected direction.", + "Ultime allerte": "Latest alerts", + "Serve TYPESAFE_API_KEY: le allerte usano Jev per valutare il mercato.": "TYPESAFE_API_KEY is needed: alerts use Jev to assess the market.", + "Le allerte sono disattivate (vedi le impostazioni in fondo).": "Alerts are disabled (see the settings at the bottom).", + "Telegram non è configurato: le allerte compaiono qui, ma non arrivano notifiche. Imposta TELEGRAM_BOT_TOKEN e TELEGRAM_CHAT_ID nel file .env.": "Telegram is not configured: alerts show up here, but no notifications are sent. Set TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID in the .env file.", + "Le fonti vengono controllate ogni {0} minuti.": "Sources are checked every {0} minutes.", + "Le fonti vengono controllate ogni {0} minuti, con la raccolta completa.": "Sources are checked every {0} minutes, with the full fetch.", + "Dopo {0}": "After {0}", + "Risultati degli ultimi {0} giorni": "Results of the last {0} days", + "Movimento a favore: di quanti punti il prezzo si è spostato nella direzione consigliata dopo l'allerta. Positivo vuol dire che l'allerta è arrivata prima del mercato. Servono decine di allerte perché i numeri dicano qualcosa.": "Favourable move: how many points the price moved in the recommended direction after the alert. Positive means the alert came before the market. You need dozens of alerts before the numbers mean something.", + "valutazione": "assessment", + "valutazioni": "assessments", + "Notificate": "Notified", + "su Telegram": "on Telegram", + "A favore dopo 1 ora": "Favourable after 1 hour", + "nessun dato ancora": "no data yet", + "media {0}": "average {0}", + "Chiamate Jev (24 ore)": "Jev calls (24 hours)", + "limite giornaliero delle allerte": "daily alert limit", + "Quando": "When", + "Media a favore": "Average favourable", + "A favore": "Favourable", + "Mercati già risolti: {0} su {1} nella direzione dell'allerta.": "Markets already resolved: {0} of {1} in the direction of the alert.", + "mercato chiuso": "closed market", + "mercati chiusi": "closed markets", + "Fino alla chiusura ({0}): in media {1} nella direzione dell'allerta, a favore nel {2} dei casi. ": "Up to the close ({0}): on average {1} in the direction of the alert, favourable in {2} of cases. ", + "È il segnale più affidabile che le allerte anticipano davvero il mercato.": "It is the most reliable sign that alerts really get ahead of the market.", + "Nessuna opportunità ancora": "No opportunities yet", + "Le allerte partono quando una notizia fresca e pertinente viene collegata a un mercato e Jev dice che conviene scommettere. Guarda «Tutte le valutazioni» per vedere anche i casi in cui non conveniva.": "Alerts fire when fresh, relevant news is linked to a market and Jev says the bet is worth it. See «All assessments» to also see the cases where it was not.", + "Nessuna valutazione ancora": "No assessments yet", + "Appena arriva una notizia importante per un mercato, compare qui.": "As soon as important news arrives for a market, it shows up here.", + "Le allerte sono disattivate.": "Alerts are disabled.", + " Notificata": " Notified", + " Notifica non riuscita": " Notification failed", + "Non notificata": "Not notified", + "Compra {0}": "Buy {0}", + "stima {0}": "estimate {0}", + "Vantaggio stimato sulla quota {0}": "Estimated edge on the {0} share", + "Adesso": "Now", + "Inizio ore silenziose": "Quiet hours start", + "Fine ore silenziose": "Quiet hours end", + "Pertinenza minima": "Minimum match", + "Categorie": "Categories", + "Quando notificare": "When to notify", + "Solo quando conviene": "Only when it is worth it", + "Anche quando conviene con una puntata piccola": "Also when it is worth it with a small stake", + "Salva": "Save", + "Impostazioni delle allerte salvate": "Alert settings saved", + "Invia un messaggio di prova": "Send a test message", + "Configura prima TELEGRAM_BOT_TOKEN e TELEGRAM_CHAT_ID": "Set TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID first", + "Invia messaggio di prova": "Send test message", + "Messaggio inviato: controlla Telegram": "Message sent: check Telegram", + "Impostazioni delle allerte": "Alert settings", + "Solo un amministratore può modificarle": "Only an administrator can change them", + "Allerte attive": "Alerts on", + "Notifiche su Telegram": "Telegram notifications", + "Telegram configurato.": "Telegram configured.", + "Telegram non configurato (file .env).": "Telegram not configured (.env file).", + "Categorie da seguire": "Categories to follow", + "Nessuna selezionata = tutte.": "None selected = all.", + "Età massima della notizia": "Maximum news age", + "Chiamate Jev al giorno": "Jev calls per day", + "Pausa per mercato dopo un'allerta": "Pause per market after an alert", + "Ore silenziose ({0})": "Quiet hours ({0})", + "In queste ore le notifiche arrivano senza suono.": "During these hours notifications arrive silently.", + "In corso": "Running", + "Completato": "Completed", + "Interrotto": "Stopped", + "Non riuscito": "Failed", + "Come avrebbe previsto Jev i mercati già risolti, con le notizie e il prezzo di allora. Serve a capire se e quanto fidarsi di Jev prima di usare soldi veri.": "How Jev would have forecast markets already resolved, with the news and the price of the time. It helps you understand whether and how much to trust Jev before using real money.", + "Attenzione al senno di poi. ": "Beware of hindsight. ", + "Jev potrebbe conoscere già come sono finiti gli eventi passati. ": "Jev might already know how past events ended. ", + "I risultati sono affidabili solo per mercati risolti dopo la data fino a cui arrivano le conoscenze del modello di Jev; per quelli più vecchi possono sembrare migliori di quanto sono.": "Results are reliable only for markets resolved after the date up to which the knowledge of Jev's model goes; for older ones they can look better than they are.", + "Notizie di allora. ": "News of the time. ", + "L'archivio di questa app contiene solo ciò che era già stato salvato a quella data: nessuna notizia successiva può entrare. ": "This app's archive only contains what had already been saved at that date: no later news can get in. ", + "Google News copre qualsiasi periodo, ma i suoi filtri per data lasciano passare articoli aggiornati dopo: quei casi possono sembrare migliori di quanto sono. I risultati dei soli casi con l'archivio sono mostrati a parte.": "Google News covers any period, but its date filters let through articles updated later: those cases can look better than they are. Results of the archive-only cases are shown separately.", + "Il book storico non esiste: le scommesse simulate usano il prezzo di allora più metà dello spread tipico e le commissioni della categoria.": "The historical book does not exist: simulated bets use the price of the time plus half the typical spread and the category fees.", + "Nessun backtest ancora": "No backtest yet", + "Scegli un periodo e avvia il primo backtest qui sopra.": "Choose a period and start the first backtest above.", + "Un amministratore deve avviare il primo backtest.": "An administrator needs to start the first backtest.", + "Archivio, poi Google News": "Archive, then Google News", + "Solo archivio (nessun senno di poi)": "Archive only (no hindsight)", + "Solo Google News": "Google News only", + "1 giorno prima": "1 day before", + "7 giorni prima": "7 days before", + "30 giorni prima": "30 days before", + "caso": "case", + "casi": "cases", + "chiamata": "call", + "chiamate": "calls", + "Fino a {0} (mercati × orizzonti), al massimo {1} a Jev. ": "Up to {0} (markets × horizons), at most {1} to Jev. ", + "I casi senza notizie, senza prezzo storico o già decisi dal prezzo non consumano chiamate.": "Cases without news, without a historical price or already decided by the price do not use calls.", + "Serve TYPESAFE_API_KEY": "TYPESAFE_API_KEY is needed", + "Avvia backtest": "Start backtest", + "Scegli almeno un orizzonte": "Choose at least one horizon", + "Scegli almeno un tipo di mercato": "Choose at least one market type", + "Backtest avviato: puoi continuare a usare la dashboard.": "Backtest started: you can keep using the dashboard.", + "Nuovo backtest": "New backtest", + "Mercati chiusi dal": "Markets closed since", + "Meglio dopo la data fino a cui arrivano le conoscenze di Jev": "Better after the date up to which Jev's knowledge goes", + "Mercati (i più scambiati)": "Markets (most traded)", + "Volume minimo ($)": "Minimum volume ($)", + "Quando fare la previsione": "When to make the forecast", + "Orizzonti": "Horizons", + "Limite di chiamate a Jev": "Jev call limit", + "Notizie di allora": "News of the time", + "L'archivio copre solo il periodo in cui l'app era attiva": "The archive only covers the period in which the app was running", + "Tipo di mercati": "Market type", + "Più esiti: una chiamata per evento; gli esiti mostrati a Jev sono i più probabili secondo il prezzo di allora": "Multi-outcome: one call per event; the outcomes shown to Jev are the most likely according to the price of the time", + "Salta i mercati già decisi dal prezzo (sotto il 3% o sopra il 97%)": "Skip markets already decided by the price (below 3% or above 97%)", + "{0} valutati · {1} saltati · {2} errori · su {3}": "{0} assessed · {1} skipped · {2} errors · of {3}", + "Interrompi": "Stop", + "Backtest in corso": "Backtest running", + "Backtest completato": "Backtest completed", + "Backtest: {0}": "Backtest: {0}", + "Valore del file .env: {0}": "Value in the .env file: {0}", + "modificato dalla dashboard": "changed from the dashboard", + "dal file .env": "from the .env file", + "Ripristina i valori del .env": "Restore the .env values", + "Valori del .env ripristinati": ".env values restored", + "Parametri in uso": "Parameters in use", + "Peso massimo di Jev": "Jev maximum weight", + "Edge minimo per un segnale": "Minimum edge for a signal", + "Calibrazione di Jev": "Jev calibration", + "stimata dal backtest": "estimated by the backtest", + "nessuna correzione": "no correction", + "nessuna": "none", + " (Jev troppo sicuro)": " (Jev overconfident)", + " (Jev troppo prudente)": " (Jev too cautious)", + "a {0} · b {1}{2}": "a {0} · b {1}{2}", + "Backtest eseguiti": "Backtests run", + "Avviato": "Started", + "Mercati chiusi": "Closed markets", + "Stato": "Status", + "Brier Jev": "Jev Brier", + "Brier prezzo": "Price Brier", + "Profitto": "Profit", + " (95%: da {0} a {1})": " (95%: from {0} to {1})", + "{0} batte il prezzo anche nel caso peggiore dell'intervallo: il vantaggio non sembra dovuto al caso.": "{0} beats the price even in the worst case of the interval: the advantage does not look like chance.", + "{0} fa peggio del prezzo anche nel caso migliore dell'intervallo.": "{0} does worse than the price even in the best case of the interval.", + "L'intervallo comprende lo zero: con questi mercati non si può dire se {0} batta davvero il prezzo.": "The interval includes zero: with these markets you cannot say whether {0} really beats the price.", + "Nessun caso valutato.": "No cases assessed.", + "Jev e il prezzo di mercato hanno previsto con la stessa accuratezza.": "Jev and the market price forecast with the same accuracy.", + "Jev ha previsto meglio del prezzo di mercato (Brier {0} contro {1}: più basso è meglio).": "Jev forecast better than the market price (Brier {0} against {1}: lower is better).", + "Il prezzo di mercato ha previsto meglio di Jev (Brier {0} contro {1}: più basso è meglio).": "The market price forecast better than Jev (Brier {0} against {1}: lower is better).", + "Brier blended": "Blended Brier", + "Segnali giusti": "Right signals", + "Scommesse": "Bets", + "Nessun caso valutato con Jev in questo backtest.": "No case assessed with Jev in this backtest.", + "Backtest del {0} · {1}{2}": "Backtest of {0} · {1}{2}", + "Brier score: errore medio al quadrato tra probabilità prevista ed esito (0 = perfetto, 0,25 = dire sempre 50%). Più basso è meglio.": "Brier score: mean squared error between forecast probability and outcome (0 = perfect, 0.25 = always saying 50%). Lower is better.", + "Il blended": "The blend", + "Solo {0} diversi: i numeri possono cambiare molto con altri mercati. Ne servono almeno 30, meglio 100.": "Only {0} distinct: the numbers can change a lot with other markets. At least 30 are needed, 100 is better.", + "Casi valutati": "Cases assessed", + "{0} · {1} saltati": "{0} · {1} skipped", + "{0} saltati": "{0} skipped", + "prezzo {0} · blended {1}": "price {0} · blended {1}", + "Vantaggio del blended sul prezzo": "Blend advantage over the price", + "Brier del prezzo − Brier del blended; > 0 è meglio": "Price Brier − blended Brier; > 0 is better", + "Scommesse simulate": "Simulated bets", + "{0} vinte su {1} · ROI {2}": "{0} won of {1} · ROI {2}", + "nessuna scommessa": "no bets", + "Notizie: {0} casi dall'archivio, {1} da Google News. ": "News: {0} cases from the archive, {1} from Google News. ", + "Solo archivio (nessun senno di poi): {0}, Brier Jev {1} contro prezzo {2}, vantaggio del blended {3}.": "Archive only (no hindsight): {0}, Jev Brier {1} against price {2}, blend advantage {3}.", + "Nessun caso con le sole notizie dell'archivio: i risultati possono risentire del senno di poi.": "No case with archive news only: results may suffer from hindsight.", + "Per orizzonte e per categoria": "By horizon and by category", + "Previsione fatta": "Forecast made", + "giorno": "day", + "giorni": "days", + "{0} {1} prima": "{0} {1} before", + "Categoria": "Category", + "Se Jev dice 70% per 10 mercati, circa 7 dovrebbero risolversi SÌ. I punti sopra la diagonale indicano previsioni troppo prudenti, quelli sotto troppo ottimiste.": "If Jev says 70% for 10 markets, about 7 should resolve YES. Dots above the diagonal mean forecasts that were too cautious, those below too optimistic.", + "Jev ha previsto meglio del mercato": "Jev forecast better than the market", + "Il mercato ha previsto meglio di Jev": "The market forecast better than Jev", + "Jev e il mercato hanno previsto con la stessa accuratezza": "Jev and the market forecast with the same accuracy", + "Brier a più esiti: somma degli errori al quadrato su tutti gli esiti (0 = perfetto, 2 = certo e sbagliato). «Favorito giusto»: quante volte l'esito più probabile ha vinto davvero.": "Multi-outcome Brier: sum of the squared errors over all outcomes (0 = perfect, 2 = certain and wrong). «Right favourite»: how often the most likely outcome actually won.", + "{0} (Brier {1} contro {2}: più basso è meglio).": "{0} (Brier {1} against {2}: lower is better).", + "Vantaggio del blended sul prezzo: {0}. ": "Blend advantage over the price: {0}. ", + " In {0} degli eventi il vincitore era tra gli «altri esiti» (poco probabile per il prezzo di allora).": " In {0} of the events the winner was among the «other outcomes» (unlikely for the price of the time).", + "Solo {0} eventi: servono molti più casi per conclusioni affidabili.": "Only {0} events: many more cases are needed for reliable conclusions.", + "Eventi valutati": "Events assessed", + "blended {0}": "blended {0}", + "Probabilità data al vincitore": "Probability given to the winner", + "Jev · mercato {0}": "Jev · market {0}", + "Favorito giusto": "Right favourite", + "Evento": "Event", + "Ha vinto": "Winner", + "Mercato sul vincitore": "Market on the winner", + "Jev sul vincitore": "Jev on the winner", + "Scommessa": "Bet", + "Parametri suggeriti": "Suggested parameters", + "Questo backtest è stato eseguito con una versione precedente, senza la verifica sui mercati più recenti né la calibrazione di Jev. Avvia un nuovo backtest per avere suggerimenti verificati.": "This backtest was run with an earlier version, without the check on the most recent markets or Jev's calibration. Start a new backtest to get verified suggestions.", + "Applica i valori consigliati": "Apply the recommended values", + "Backtest su {0} mercati, verificato sui più recenti": "Backtest on {0} markets, verified on the most recent", + "Parametri aggiornati: valgono per le prossime previsioni": "Parameters updated: they apply to the next forecasts", + "il valore attuale è già il migliore": "the current value is already the best", + "non verificato: servono più mercati": "not verified: more markets are needed", + "consigliato: migliora anche sui mercati più recenti": "recommended: it also improves on the most recent markets", + "non consigliato: sui mercati più recenti non migliora": "not recommended: it does not improve on the most recent markets", + "Verifica: parametri stimati sui {0} mercati chiusi prima e provati sugli altri {1}. ": "Check: parameters estimated on the {0} markets closed first and tried on the other {1}. ", + "Brier del blended su questi ultimi: {0} con i valori attuali, {1} con quelli stimati.": "Blended Brier on the latter: {0} with the current values, {1} with the estimated ones.", + "Non verificabile: servono almeno 10 mercati sia per stimare sia per provare (qui {0} + {1}). Nessun valore viene consigliato.": "Not verifiable: at least 10 markets are needed both to estimate and to test (here {0} + {1}). No value is recommended.", + "affidabilità {0} · {1}": "reliability {0} · {1}", + "{0} → {1} pt": "{0} → {1} pts", + "servono almeno 10 scommesse per suggerirlo": "at least 10 bets are needed to suggest it", + "Risultato per soglia di edge (1 $ per segnale, tutti i mercati)": "Result by edge threshold ($1 per signal, all markets)", + "Vinte": "Won", + "La calibrazione corregge le probabilità di Jev (troppo sicure o troppo prudenti) prima di unirle al prezzo. ": "Calibration corrects Jev's probabilities (overconfident or too cautious) before combining them with the price. ", + "Un valore è consigliato solo se, stimato sui mercati più vecchi, migliora anche i più recenti; i valori mostrati sono poi ristimati su tutti. ": "A value is recommended only if, estimated on the older markets, it also improves the most recent ones; the values shown are then re-estimated on all of them. ", + "Valgono per le previsioni future; puoi tornare ai valori del .env quando vuoi.": "They apply to future forecasts; you can go back to the .env values whenever you want.", + "notizia": "news item", + "notizie": "news items", + " Compra SÌ": " Buy YES", + " Compra NO": " Buy NO", + "Mostra tutti i {0} casi": "Show all {0} cases", + "Casi valutati ({0})": "Cases assessed ({0})", + "prima le vincite e le perdite più grandi": "biggest wins and losses first", + "Esito": "Outcome", + "Casi saltati:": "Skipped cases:", + "Valuta tutti con Jev": "Assess all with Jev", + "Nessun mercato aperto ha notizie recenti collegate. Aggiorna notizie e mercati.": "No open market has linked recent news. Update news and markets.", + "Valutazione in corso": "Assessment running", + "Chiede una previsione a Jev per {0} con notizie recenti": "Asks Jev for a forecast for {0} with recent news", + "circa un minuto": "about a minute", + "circa {0} minuti": "about {0} minutes", + "{0} a pagamento a TypeSafe, una per mercato. ": "{0} paid to TypeSafe, one per market. ", + "Con il limite di {0} richieste al minuto servono {1}. ": "With the limit of {0} requests per minute it takes {1}. ", + "Prima vengono aggiornati prezzi e collegamenti; le scommesse simulate seguono le regole del portafoglio.": "Prices and links are updated first; simulated bets follow the portfolio rules.", + "Avvia valutazione": "Start assessment", + "Valutazione avviata: puoi continuare a usare la dashboard.": "Assessment started: you can keep using the dashboard.", + "Valutare tutti i mercati con Jev?": "Assess all markets with Jev?", + "Quali mercati": "Which markets", + "Tutti quelli con notizie recenti ({0})": "All those with recent news ({0})", + "Solo mai valutati o con notizie nuove ({0})": "Only never assessed or with new news ({0})", + "Annulla": "Cancel", + "{0} di {1} valutati": "{0} of {1} assessed", + "{0} con errore": "{0} with errors", + "Valutazione Jev in corso": "Jev assessment running", + "Avanzamento": "Progress", + "Ora: ": "Now: ", + "Valutazione di tutti i mercati": "Assessment of all markets", + "Completata": "Completed", + "Come funziona la valutazione": "How the assessment works", + "Dalle notizie a un segnale su Polymarket, passo per passo. I valori evidenziati sono quelli configurati su questo server.": "From the news to a signal on Polymarket, step by step. The highlighted values are the ones configured on this server.", + "Cosa dice il prezzo di un mercato": "What a market's price says", + "Su Polymarket ogni mercato è una domanda con risposta SÌ o NO, per esempio «La Fed taglierà i tassi a dicembre?». ": "On Polymarket each market is a question with a YES or NO answer, for example «Will the Fed cut rates in December?». ", + "Una quota SÌ paga 1 $ se l'evento accade e 0 se non accade. Se costa ": "A YES share pays $1 if the event happens and 0 if it does not. If it costs ", + ", chi compra e chi vende stanno dicendo che l'evento ha circa il ": ", buyers and sellers are saying the event has about a ", + " di probabilità.": " probability.", + "Scommettere ha senso solo se hai motivo di pensare che la probabilità vera sia diversa dal prezzo, e di abbastanza da coprire le commissioni. ": "Betting makes sense only if you have reason to think the true probability differs from the price, and by enough to cover the fees. ", + "Questa app cerca questi casi nelle notizie recenti.": "This app looks for these cases in recent news.", + "Raccolta e classificazione delle notizie": "News collection and classification", + "Le fonti attive ({0}) vengono lette a intervalli regolari. Ogni notizia viene riassunta e classificata da Jev: ": "The active sources ({0}) are read at regular intervals. Each news item is summarised and classified by Jev: ", + "categoria, regione, autorevolezza, clickbait, se è un'opinione e quanto è ": "category, region, authority, clickbait, whether it is an opinion and how ", + "rilevante per i mercati": "relevant to markets", + "Le notizie quasi identiche di testate diverse vengono raggruppate, così lo stesso fatto non conta due volte.": "Near-identical news from different outlets is grouped, so the same fact does not count twice.", + "Collegamento notizie ↔ mercati": "Linking news ↔ markets", + "Per ogni mercato aperto l'app cerca le notizie recenti che parlano dello stesso argomento, in due modi combinati:": "For each open market the app looks for recent news about the same topic, in two combined ways:", + "Significato:": "Meaning:", + " somiglianza semantica tra la domanda e il titolo, o il titolo con l'inizio del testo (embedding).": " semantic similarity between the question and the title, or the title plus the start of the text (embeddings).", + "Termini chiave:": "Key terms:", + " nomi, sigle, numeri e parole distintive della domanda, con i sinonimi più comuni (Fed = Federal Reserve = Powell, BTC = Bitcoin, 100k = 100.000).": " names, acronyms, numbers and distinctive words of the question, with the most common synonyms (Fed = Federal Reserve = Powell, BTC = Bitcoin, 100k = 100,000).", + "Una notizia sullo stesso tema ma su un soggetto diverso (la BCE per un mercato sulla Fed) non cita nessun nome della domanda e viene scartata.": "News on the same topic but about a different subject (the ECB for a market on the Fed) does not mention any name in the question and is discarded.", + "Per i mercati più scambiati l'app cerca anche su Google News le notizie che contengono i termini chiave: così arrivano notizie anche su argomenti che le fonti abituali non coprono.": "For the most traded markets the app also searches Google News for news containing the key terms: this way news arrives even on topics the usual sources do not cover.", + "Finestra": "Window", + "{0} ore": "{0} hours", + "Notizie per previsione": "News per forecast", + "Quali notizie legge Jev": "Which news Jev reads", + "Delle notizie collegate Jev legge le {0} più utili. L'utilità tiene conto di:": "Of the linked news Jev reads the {0} most useful. Usefulness takes into account:", + "Pertinenza": "Match", + " con la domanda.": " with the question.", + "Affidabilità della fonte:": "Source reliability:", + " autorevolezza, clickbait e articoli d'opinione.": " authority, clickbait and opinion pieces.", + "Freschezza:": "Freshness:", + " il peso si dimezza ogni due giorni.": " the weight halves every two days.", + "Giudizio di Jev:": "Jev's judgement:", + " le notizie che Jev ha già giudicato non rilevanti per quel mercato vengono tolte.": " news that Jev has already judged not relevant for that market is removed.", + "Per ogni storia passa un solo articolo, con il numero di fonti che l'hanno riportata: una notizia confermata da più testate è più credibile. ": "Only one article per story gets through, with the number of sources that reported it: news confirmed by several outlets is more credible. ", + "Al massimo 3 articoli per fonte, così le evidenze sono varie.": "At most 3 articles per source, so the evidence is varied.", + "Le domande a Jev": "The questions to Jev", + "Per ogni mercato Jev riceve le regole di risoluzione, la scadenza, la data di oggi e le notizie collegate, e risponde in una sola chiamata a:": "For each market Jev receives the resolution rules, the end date, today's date and the linked news, and answers in a single call:", + "Il mercato si risolverà SÌ?": "Will the market resolve YES?", + " → una probabilità (la stima di Jev).": " → a probability (Jev's estimate).", + "Quanto le notizie informano l'esito?": "How much does the news inform the outcome?", + " → la forza delle evidenze, da 0% a 100%.": " → the evidence strength, from 0% to 100%.", + "Per ogni notizia:": "For each news item:", + " è rilevante? favorisce il SÌ, il NO o è neutra?": " is it relevant? does it favour YES, NO or is it neutral?", + "Jev non vede il prezzo di mercato.": "Jev does not see the market price.", + " Se lo vedesse tenderebbe a copiarlo, e la sua stima non direbbe nulla di nuovo.": " If it did it would tend to copy it, and its estimate would say nothing new.", + "Un mercato liquido aggrega le opinioni di molte persone e di solito è ben calibrato. Per questo la stima di Jev non sostituisce il prezzo: ": "A liquid market aggregates the opinions of many people and is usually well calibrated. That is why Jev's estimate does not replace the price: ", + "si fa una media pesata, e il peso di Jev cresce solo quando le notizie sono forti.": "a weighted average is taken, and Jev's weight grows only when the news is strong.", + "Calibrazione.": "Calibration.", + " Prima, la stima di Jev viene corretta con quanto si è visto sui mercati già risolti: se Jev è stato troppo sicuro di sé la sua stima viene ammorbidita, se è stato troppo prudente viene resa più netta (scala di Platt, stimata dal backtest). ": " First, Jev's estimate is corrected with what has been seen on markets already resolved: if Jev was overconfident its estimate is softened, if it was too cautious it is sharpened (Platt scaling, estimated by the backtest). ", + "Per ora nessuna correzione è attiva.": "For now no correction is active.", + "Correzione attiva: a {0}, b {1}.": "Active correction: a {0}, b {1}.", + "logit(Jev corretto) = a + b × logit(stima Jev)": "logit(corrected Jev) = a + b × logit(Jev estimate)", + "w = {0} × forza delle evidenze": "w = {0} × evidence strength", + "blended = w × Jev corretto + (1 − w) × prezzo": "blended = w × corrected Jev + (1 − w) × price", + "logit(blended) = w × logit(Jev corretto) + (1 − w) × logit(prezzo)": "logit(blended) = w × logit(corrected Jev) + (1 − w) × logit(price)", + "La media si fa sulle quote logaritmiche (logit p = ln(p / (1 − p))): è il modo standard di unire previsioni ben calibrate. Una media semplice le rende troppo timide.": "The average is taken on the log-odds (logit p = ln(p / (1 − p))): it is the standard way to combine well-calibrated forecasts. A simple average makes them too timid.", + "Ogni previsione chiede a Jev {0} volte e fa la media delle risposte: meno rumore, ma ogni chiamata si paga.": "Each forecast asks Jev {0} times and averages the answers: less noise, but every call is paid.", + "Con evidenze deboli la probabilità finale resta vicina al prezzo; con evidenze decisive Jev può pesare al massimo il ": "With weak evidence the final probability stays close to the price; with decisive evidence Jev can weigh at most ", + "Edge e soglie": "Edge and thresholds", + "edge = blended − prezzo": "edge = blended − price", + "Un segnale compare solo se ": "A signal appears only if ", + "|edge| ≥ {0} punti": "|edge| ≥ {0} points", + "forza delle evidenze ≥ {0}": "evidence strength ≥ {0}", + ". Edge positivo: ": ". Positive edge: ", + ". Edge negativo: ": ". Negative edge: ", + ". Altrimenti: ": ". Otherwise: ", + "La puntata segue il criterio di Kelly, che massimizza la crescita del capitale nel lungo periodo se le probabilità sono giuste. ": "The stake follows the Kelly criterion, which maximises long-run capital growth if the probabilities are right. ", + "Poiché le probabilità sono stime, si usa solo una frazione: ": "Since the probabilities are estimates, only a fraction is used: ", + "{0} × Kelly": "{0} × Kelly", + "Kelly (SÌ) = (blended − prezzo) / (1 − prezzo)": "Kelly (YES) = (blended − price) / (1 − price)", + "Conviene davvero? La valutazione economica": "Is it really worth it? The economic assessment", + "Un edge sulla carta non basta. Prima di suggerire una puntata l'app controlla:": "An edge on paper is not enough. Before suggesting a stake the app checks:", + "Prezzo reale:": "Real price:", + " legge il book di Polymarket e calcola il prezzo medio che pagheresti per quella cifra, commissioni incluse. Lo spread spesso si mangia l'edge.": " it reads the Polymarket book and computes the average price you would pay for that amount, fees included. The spread often eats the edge.", + "Incertezza:": "Uncertainty:", + " la probabilità viene abbassata di z volte il suo errore tipico (più notizie forti, meno errore). Le decisioni usano questa probabilità prudente.": " the probability is lowered by z times its typical error (more strong news, less error). Decisions use this prudent probability.", + "Tempo:": "Time:", + " il rendimento atteso viene annualizzato e confrontato con una soglia (tasso senza rischio + premio). Pochi punti su un mercato che si chiude tra un anno non valgono i soldi bloccati.": " the expected return is annualised and compared with a threshold (risk-free rate + premium). A few points on a market that closes in a year are not worth the locked-up money.", + "Quanto puntare:": "How much to stake:", + " Kelly calcolato sul book (puntare di più peggiora il prezzo), una frazione di Kelly, poi i limiti per mercato, evento, categoria, totale investito e profondità del book.": " Kelly computed on the book (staking more worsens the price), a fraction of Kelly, then the limits per market, event, category, total invested and book depth.", + "I tre preset (prudente, bilanciato, aggressivo) cambiano queste soglie. Il verdetto è ": "The three presets (prudent, balanced, aggressive) change these thresholds. The verdict is ", + " (puntata ridotta dai limiti) o ": " (stake reduced by the limits) or ", + ", sempre con i motivi.": ", always with the reasons.", + "Ogni scommessa che conviene entra automaticamente nel ": "Every bet that is worth it enters the ", + "portafoglio simulato": "simulated portfolio", + ", al prezzo reale del momento, e si chiude quando il mercato si risolve. Puoi escludere singole scommesse, mercati, eventi o categorie.": " automatically, at the real price of the moment, and closes when the market resolves. You can exclude single bets, markets, events or categories.", + "Cosa fare: comprare, aspettare, vendere": "What to do: buy, wait, sell", + "La scheda «Cosa fare» del mercato trasforma tutto questo in istruzioni:": "The market's «What to do» card turns all this into instructions:", + "Compra SÌ / NO": "Buy YES / NO", + " con un ordine limite: il prezzo massimo, le quote e la cifra. Appena comprate, un ordine limite di vendita.": " with a limit order: the maximum price, the shares and the amount. Once bought, a limit sell order.", + " quando il vantaggio c'è ma al prezzo attuale costi e incertezza se lo mangiano: l'app indica il prezzo a cui conviene (ordine in attesa).": " when the edge is there but at the current price costs and uncertainty eat it up: the app shows the price at which it pays (pending order).", + " quando il problema non è il prezzo: mercato poco liquido, scadenza troppo lontana, limiti di rischio già pieni.": " when the problem is not the price: illiquid market, end date too far away, risk limits already full.", + " quando la stima è vicina al prezzo; l'app dice comunque a che prezzo comprare SÌ o NO diventerebbe conveniente.": " when the estimate is close to the price; the app still says at what price buying YES or NO would become worth it.", + "Tieni / Vendi": "Hold / Sell", + " se hai già una posizione: si vende quando il prezzo raggiunge la stima (incassare subito rende più che aspettare la risoluzione, tenuto conto delle commissioni e del rendimento chiesto al capitale) o quando una nuova previsione gira la stima.": " if you already have a position: you sell when the price reaches the estimate (cashing in now pays more than waiting for resolution, given the fees and the return asked of capital) or when a new forecast turns the estimate.", + "vendi se prezzo − commissione ≥ stima al prezzo attuale ÷ (1 + rendimento richiesto)^(giorni/365)": "sell if price − fee ≥ estimate at the current price ÷ (1 + required return)^(days/365)", + "Niente stop-loss fisso: se il prezzo scende ma le notizie non cambiano, la quota è ancora più conveniente. Ogni piano elenca i motivi a favore e contro (notizie, margini, tempo, risultati passati) e un livello di fiducia.": "No fixed stop-loss: if the price falls but the news does not change, the share is even better value. Each plan lists the reasons for and against (news, margins, time, past results) and a confidence level.", + "Verifica sui risultati": "Checking the results", + "Quando un mercato si risolve, la pagina ": "When a market resolves, the ", + " confronta l'errore (Brier score) delle previsioni con quello del prezzo; il ": " page compares the error (Brier score) of the forecasts with that of the price; the ", + " mostra se, dopo costi e limiti, i segnali avrebbero fatto guadagnare. Se su molti mercati non battono il prezzo, i segnali non vanno seguiti con soldi veri.": " shows whether, after costs and limits, the signals would have made money. If over many markets they do not beat the price, the signals should not be followed with real money.", + "Esempio con numeri": "Worked example", + "Numeri inventati per illustrare il calcolo, con i parametri di questo server.": "Made-up numbers to illustrate the calculation, with this server's parameters.", + "Evidenze": "Evidence", + "Peso w": "Weight w", + "Cosa tenere presente": "What to keep in mind", + "L'app non piazza ordini: i segnali sono indicazioni da verificare.": "The app does not place orders: signals are suggestions to be checked.", + "Il segnale non considera commissioni e spread: li considera la valutazione economica («Conviene?»), che decide se e quanto puntare.": "The signal does not consider fees and spread: the economic assessment («Worth it?») does, and decides whether and how much to stake.", + "Il prezzo cambia: una previsione vecchia di ore può non valere più. Controlla sempre il prezzo attuale.": "The price changes: a forecast that is hours old may no longer hold. Always check the current price.", + "Il collegamento usa i titoli: una notizia importante con un titolo diverso dalla domanda può sfuggire.": "Linking uses titles: important news with a title different from the question can slip through.", + "Jev può sbagliare, soprattutto con poche notizie o regole di risoluzione complicate. Leggi le regole del mercato.": "Jev can be wrong, especially with little news or complicated resolution rules. Read the market rules.", + "Glossario": "Glossary", + "Puntata (Kelly)": "Stake (Kelly)", + "Rilevanza per i mercati": "Relevance to markets", + "Edge più alto": "Highest edge", + "Prezzo (normalizzato)": "Price (normalised)", + ", Jev {0}, blended {1}": ", Jev {0}, blended {1}", + "{0}: prezzo {1}{2}": "{0}: price {1}{2}", + "Prezzo di mercato (normalizzato a 100%)": "Market price (normalised to 100%)", + "comprando il SÌ di tutti gli esiti": "buying the YES of every outcome", + "comprando il NO di tutti gli esiti": "buying the NO of every outcome", + "Costo {0} per un set che paga {1} in ogni caso, {2}, commissioni incluse. Solo al miglior prezzo del book: la quantità disponibile può essere piccola e il prezzo cambiare in fretta.": "Cost {0} for a set that pays {1} in any case, {2}, fees included. Only at the best price in the book: the available quantity can be small and the price change quickly.", + "Arbitraggio {0} per set": "Arbitrage {0} per set", + "Compra {0} su {1} · {2}": "Buy {0} on {1} · {2}", + "{0} a max {1}": "{0} at max {1}", + "evento": "event", + "eventi": "events", + "Nessun evento": "No events", + "Nessun evento o esito corrisponde alla ricerca.": "No event or outcome matches the search.", + "Gli eventi a più esiti arrivano con l'aggiornamento dei mercati.": "Multi-outcome events arrive with the market update.", + "Impossibile caricare gli eventi": "Could not load the events", + "Cerca un evento o un esito (es. elezioni, Arsenal)": "Search an event or an outcome (e.g. elections, Arsenal)", + "Cerca un evento o un esito": "Search an event or an outcome", + "Eventi con più risposte possibili, di cui una sola vincerà (elezioni, campionati, premi). Ogni esito è una quota SÌ/NO; qui si leggono insieme, come una distribuzione di probabilità.": "Events with several possible answers, only one of which will win (elections, leagues, awards). Each outcome is a YES/NO share; here they are read together, as a probability distribution.", + "esito": "outcome", + "esiti": "outcomes", + "altro esito": "other outcome", + "altri esiti": "other outcomes", + "Dettaglio": "Detail", + "L'evento è chiuso.": "The event is closed.", + "Nessuna notizia recente collegata a questo evento.": "No recent news linked to this event.", + "Previsione salvata: compra {0} su {1} ({2})": "Forecast saved: buy {0} on {1} ({2})", + "Previsione salvata: nessun esito abbastanza lontano dal suo prezzo": "Forecast saved: no outcome far enough from its price", + "Quota SÌ": "YES share", + "Prezzo normalizzato": "Normalised price", + "Evento Polymarket a più esiti": "Multi-outcome Polymarket event", + "Ha vinto: {0}": "Winner: {0}", + "Distribuzione: mercato e Jev": "Distribution: market and Jev", + "Distribuzione secondo il mercato": "Distribution according to the market", + "Jev ha letto {0} e ha stimato la probabilità di ogni esito senza vedere i prezzi. Evidenze {1}: la sua stima pesa per il {2}. Previsione {3}. ": "Jev read {0} and estimated the probability of each outcome without seeing the prices. Evidence {1}: its estimate weighs {2}. Forecast {3}. ", + "I prezzi delle quote SÌ sono normalizzati in modo che la somma faccia 100%. ": "YES share prices are normalised so that they add up to 100%. ", + "Edge = blended − prezzo della quota SÌ (quello che si paga davvero). Il segnale indica l'esito più lontano dal suo prezzo, se l'edge supera la soglia minima e le notizie sono abbastanza forti: SÌ se è sottovalutato, NO se è sopravvalutato (spesso un favorito su cui il mercato è troppo ottimista). Jev valuta i 12 esiti più probabili; gli altri sono sommati in «Altri esiti».": "Edge = blended − price of the YES share (what you actually pay). The signal points to the outcome furthest from its price, if the edge exceeds the minimum threshold and the news is strong enough: YES if it is undervalued, NO if it is overvalued (often a favourite the market is too optimistic about). Jev assesses the 12 most likely outcomes; the others are summed in «Other outcomes».", + "Come si legge?": "How to read it?", + "Una chiamata all'API TypeSafe per tutto l'evento.": "One call to the TypeSafe API for the whole event.", + "Una per storia, dalla più utile": "One per story, most useful first", + "Favorisce {0}": "Favours {0}", + "Nessuna notizia recente riguarda questo evento.": "No recent news concerns this event.", + "Esito più lontano dal prezzo": "Outcome furthest from the price", + "{0} · {1} · edge {2}": "{0} · {1} · edge {2}", + "Esito da valutare": "Outcome to assess", + "Qualsiasi data": "Any date", + "Ultime 24 ore": "Last 24 hours", + "Ultimi 3 giorni": "Last 3 days", + "Ultimi 7 giorni": "Last 7 days", + "Ultimi 30 giorni": "Last 30 days", + "risultato": "result", + "risultati": "results", + ", mostrati {0}": ", {0} shown", + "{0} per «{1}»{2}": "{0} for «{1}»{2}", + "Nessuna notizia trovata": "No news found", + "Prova con meno parole, un periodo più lungo o meno filtri.": "Try fewer words, a longer period or fewer filters.", + "Azzera ricerca e filtri": "Clear search and filters", + "Nessuna notizia": "No news", + "Premi «Aggiorna notizie» per scaricarle dalle fonti attive.": "Press «Update news» to fetch it from the active sources.", + "Le notizie compaiono dopo il prossimo aggiornamento automatico.": "News appears after the next automatic update.", + "Carica altre": "Load more", + "Cerca nelle notizie: parole, \"frase esatta\", -escludi": "Search the news: words, \"exact phrase\", -exclude", + "Cerca nelle notizie": "Search the news", + "Ordinamento automatico": "Automatic sorting", + "Più pertinenti": "Most relevant", + "Punteggio più alto": "Highest score", + "Più recenti": "Newest", + "Ordina": "Sort", + "Cerca in": "Search in", + "Titolo, testo e riassunto": "Title, text and summary", + "Solo titolo": "Title only", + "Tutte": "All", + "Fonte": "Source", + "Tutte le fonti": "All sources", + "Periodo": "Period", + "Regione": "Region", + "Solo rilevanti per i mercati": "Relevant to markets only", + "Escludi opinioni": "Exclude opinions", + "Personalizza il punteggio": "Customise the score", + "Autorevolezza": "Authority", + "Profondità": "Depth", + "Urgenza": "Urgency", + "Penalità clickbait": "Clickbait penalty", + "Clickbait massimo": "Maximum clickbait", + "Autorevolezza minima": "Minimum authority", + "Riassunte e classificate da Jev. Le notizie quasi identiche di più testate sono raggruppate.": "Summarised and classified by Jev. Near-identical news from several outlets is grouped.", + "Tutte le parole devono comparire. Usa le virgolette per una frase esatta, «-parola» per escludere, «or» per alternative.": "All words must appear. Use quotes for an exact phrase, «-word» to exclude, «or» for alternatives.", + "Classificata da Jev, confidenza {0}": "Classified by Jev, confidence {0}", + "Classificata con l'euristica locale": "Classified with the local heuristic", + "Rilevante per i mercati": "Relevant to markets", + "Opinione": "Opinion", + "Punteggio": "Score", + "Quanto è verificata e ben sostenuta da fonti": "How verified and well supported by sources it is", + "Livello di analisi tecnica o quantitativa": "Level of technical or quantitative analysis", + "Quanto è una notizia in evoluzione": "How much it is a developing story", + "Clickbait": "Clickbait", + "Sensazionalismo del titolo (più basso è meglio)": "Sensationalism of the title (lower is better)", + "Per i mercati": "For markets", + "Vinta": "Won", + "Persa": "Lost", + "Annullata 50-50": "Voided 50-50", + "Venduta": "Sold", + "Aperta": "Open", + "Esclusa": "Excluded", + "La previsione si è girata contro questa posizione": "The forecast has turned against this position", + "Il prezzo ha raggiunto la stima": "The price has reached the estimate", + "Vendi: stima girata": "Sell: estimate turned", + "Vendi: obiettivo raggiunto": "Sell: target reached", + "Ordine limite di vendita: a quel prezzo incassare rende più che aspettare la risoluzione": "Limit sell order: at that price cashing in pays more than waiting for resolution", + " · vendi a {0}": " · sell at {0}", + "Le prossime scommesse automatiche salteranno questo mercato": "The next automatic bets will skip this market", + "Escludi mercato": "Exclude market", + "Mercato escluso dalle scommesse automatiche": "Market excluded from automatic bets", + "Lato": "Side", + "Quote": "Shares", + "Costo": "Cost", + "Prezzo attuale": "Current price", + "Valore": "Value", + "Profitto latente": "Unrealised profit", + "Piano": "Plan", + "Scade": "Ends", + "Azioni": "Actions", + "Vendi ora": "Sell now", + "Scommessa venduta al prezzo del book": "Bet sold at the book price", + "Escludi": "Exclude", + "Scommessa esclusa dalla simulazione": "Bet excluded from the simulation", + "Nessuna posizione aperta.": "No open positions.", + "Atteso": "Expected", + "Risultato": "Result", + "Chiusa": "Closed", + "a {0}": "at {0}", + " · a mano": " · by hand", + "Scommessa esclusa dai risultati": "Bet excluded from the results", + "Nessuna scommessa chiusa: si chiudono quando i mercati si risolvono o quando il piano dice di vendere.": "No closed bets: they close when markets resolve or when the plan says to sell.", + "Scommesse escluse dalla simulazione ({0})": "Bets excluded from the simulation ({0})", + "Riammetti": "Readmit", + "Scommessa riammessa": "Bet readmitted", + "Andamento del capitale": "Capital over time", + "dal {0}": "since {0}", + "La curva compare quando si chiude la prima scommessa.": "The curve appears when the first bet closes.", + "Sulle {0} scommesse chiuse il modello si aspettava {1}; il risultato reale è {2} ({3} di differenza). ": "Over the {0} closed bets the model expected {1}; the actual result is {2} ({3} difference). ", + "Con meno di 30 scommesse chiuse la differenza dipende molto dal caso.": "With fewer than 30 closed bets the difference depends a lot on chance.", + "Portafoglio simulato": "Simulated portfolio", + "Ogni segnale che conviene diventa una scommessa virtuale al prezzo reale del momento, venduta quando il piano lo dice o chiusa quando il mercato si risolve. Serve a capire se i segnali fanno guadagnare prima di usare soldi veri.": "Every signal that is worth it becomes a virtual bet at the real price of the moment, sold when the plan says so or closed when the market resolves. It helps you understand whether the signals make money before using real money.", + "Valore attuale": "Current value", + "{0} da capitale {1}": "{0} from capital {1}", + "Profitti realizzati": "Realised profits", + " · {0} vendute": " · {0} sold", + " · {0} annullate": " · {0} voided", + "{0} vinte · {1} perse{2}{3}": "{0} won · {1} lost{2}{3}", + "Profitti latenti": "Unrealised profits", + "{0} aperte · {1} investiti": "{0} open · {1} invested", + "Scommesse vinte": "Bets won", + "liquidità {0}": "cash {0}", + "Prezzo di chiusura": "Closing price", + "{0} comprate sotto la chiusura · {1}": "{0} bought below the close · {1}", + "finora {0} sulle aperte": "so far {0} on the open ones", + "nessun mercato chiuso ancora": "no market closed yet", + "Ancora nessuna scommessa: la prima arriva con la prossima previsione che supera i controlli economici del preset.": "No bets yet: the first arrives with the next forecast that passes the preset's economic checks.", + "Le scommesse automatiche sono disattivate: attivale qui sotto o aggiungile dal dettaglio di un mercato.": "Automatic bets are off: turn them on below or add them from a market's detail page.", + "Posizioni aperte ({0})": "Open positions ({0})", + "Scommesse chiuse ({0})": "Closed bets ({0})", + "Preset {0} attivo per le prossime scommesse": "Preset {0} active for the next bets", + "Kelly ×{0} · max {1}% per mercato · margine ≥ {2} pt · ≤ {3} giorni": "Kelly ×{0} · max {1}% per market · margin ≥ {2} pts · ≤ {3} days", + "Scommesse automatiche attivate": "Automatic bets on", + "Scommesse automatiche disattivate": "Automatic bets off", + "Vendite automatiche attivate": "Automatic sales on", + "Vendite automatiche disattivate: le posizioni restano aperte fino alla risoluzione": "Automatic sales off: positions stay open until resolution", + "Capitale iniziale ": "Starting capital ", + "Cancella e ricomincia": "Clear and start over", + "Il capitale deve essere almeno 10 $": "Capital must be at least $10", + "Portafoglio azzerato": "Portfolio reset", + "Nuovo capitale ($)": "New capital ($)", + "Tutte le scommesse simulate verranno cancellate. Le esclusioni restano.": "All simulated bets will be deleted. Exclusions stay.", + "Impostazioni della simulazione": "Simulation settings", + "Aggiungi automaticamente ogni scommessa che conviene": "Automatically add every bet that is worth it", + "Vendi automaticamente quando il piano lo dice (prezzo arrivato alla stima, o previsione girata)": "Sell automatically when the plan says so (price reached the estimate, or forecast turned)", + "Tasso senza rischio {0}. ": "Risk-free rate {0}. ", + "Incertezza corretta ×{0} in base ai risultati passati.": "Uncertainty adjusted ×{0} based on past results.", + "L'incertezza verrà corretta con i risultati reali dopo 30 mercati risolti.": "Uncertainty will be adjusted with real results after 30 resolved markets.", + "Se le previsioni passate sono state peggiori del prezzo di mercato, l'incertezza della stima viene allargata e le puntate si riducono (e viceversa).": "If past forecasts were worse than the market price, the uncertainty of the estimate is widened and stakes shrink (and vice versa).", + "Esclusione rimossa": "Exclusion removed", + "Rimuovi": "Remove", + "Nessuna esclusione: tutte le scommesse che convengono entrano nella simulazione.": "No exclusions: all bets that are worth it enter the simulation.", + "Escludi categoria": "Exclude category", + "Categoria esclusa dalle scommesse automatiche": "Category excluded from automatic bets", + "Esclusioni": "Exclusions", + "Le scommesse automatiche saltano questi mercati, eventi e categorie. Mercati ed eventi si escludono dal loro dettaglio o dalle posizioni aperte.": "Automatic bets skip these markets, events and categories. Markets and events are excluded from their detail page or from open positions.", + "Nessuna (argomenti misti)": "None (mixed topics)", + "Fonti": "Sources", + "{0} attive su {1}": "{0} active of {1}", + " · {0} con errori": " · {0} with errors", + "Aggiungi fonte": "Add source", + "Argomento": "Topic", + "Ultimo aggiornamento": "Last update", + "Attiva": "Active", + "Nessuna fonte": "No sources", + "Aggiungi una fonte o scegline alcune tra quelle consigliate qui sotto.": "Add a source or pick some of the recommended ones below.", + "Un amministratore deve aggiungere le fonti.": "An administrator needs to add the sources.", + "Fonti delle notizie e classificazione.": "News sources and classification.", + "Fonti delle notizie e parametri. Solo gli amministratori possono modificarli.": "News sources and parameters. Only administrators can change them.", + "In attesa del primo aggiornamento": "Waiting for the first update", + "Mai scaricata": "Never fetched", + "Errore": "Error", + "Errore sconosciuto": "Unknown error", + "Nessuna novità": "Nothing new", + "Disattiva": "Deactivate", + "{0} disattivata: le sue notizie restano nell'archivio": "{0} deactivated: its news stays in the archive", + "Scarica subito le notizie di questa fonte": "Fetch this source's news now", + "Aggiorna {0}": "Update {0}", + "Modifica": "Edit", + "Elimina": "Delete", + "la sua notizia": "its news item", + "le sue {0} notizie": "its {0} news items", + "Eliminare la fonte e {0}? Per tenerle, disattivala.": "Delete the source and {0}? To keep them, deactivate it.", + "Eliminare la fonte?": "Delete the source?", + "Misti": "Mixed", + "Aggiornamento di {0} avviato": "Update of {0} started", + "Argomento prevalente": "Main topic", + "Attiva (scarica le notizie a ogni aggiornamento)": "Active (fetches the news at every update)", + "Indirizzo del feed RSS o Atom, non della pagina web.": "Address of the RSS or Atom feed, not of the web page.", + "Prova il feed": "Test the feed", + "Inserisci l'indirizzo del feed.": "Enter the feed address.", + "Scarico il feed…": "Fetching the feed…", + "Feed valido": "Valid feed", + "Non funziona": "Not working", + "Salva modifiche": "Save changes", + "Indirizzo del feed": "Feed address", + "Dai un nome alla fonte (almeno 2 caratteri).": "Give the source a name (at least 2 characters).", + "{0} aggiunta. Le notizie arrivano al prossimo aggiornamento.": "{0} added. The news arrives at the next update.", + "Modifica {0}": "Edit {0}", + "Nuova fonte": "New source", + "Aggiungi selezionate": "Add selected", + "Aggiungi selezionate ({0})": "Add selected ({0})", + "1 fonte aggiunta": "1 source added", + "{0} fonti aggiunte": "{0} sources added", + "Fonti consigliate": "Recommended sources", + "{0} fonti selezionate per i temi più scambiati su Polymarket, non ancora aggiunte.": "{0} sources selected for the most traded topics on Polymarket, not added yet.", + "Hai già aggiunto tutte le fonti consigliate.": "You have already added all the recommended sources.", + "Seleziona tutte": "Select all", + "Generaliste": "General", + " · già aggiunta": " · already added", + "Riclassifica le notizie": "Reclassify the news", + "Riclassificazione avviata: fino a 500 notizie, dalle più recenti.": "Reclassification started: up to 500 news items, newest first.", + "Classificazione": "Classification", + "Le notizie sono classificate da Jev: categoria, regione, opinione o notizia, rilevanza per i mercati, autorevolezza e clickbait.": "News is classified by Jev: category, region, opinion or news, relevance to markets, authority and clickbait.", + "Senza chiave TypeSafe le notizie sono classificate con parole chiave. Aggiungi TYPESAFE_API_KEY per usare Jev.": "Without a TypeSafe key news is classified with keywords. Add TYPESAFE_API_KEY to use Jev.", + "La riclassificazione passa a Jev le notizie classificate prima con le parole chiave. Ogni notizia è una chiamata a pagamento.": "Reclassification passes to Jev the news previously classified with keywords. Each news item is a paid call.", + "La riclassificazione applica le regole attuali alle notizie salvate prima dell'ultimo aggiornamento.": "Reclassification applies the current rules to the news saved before the latest update.", + "Parametri di previsione": "Forecast parameters", + "Si cambiano nel file .env del server e valgono al riavvio. ": "They are changed in the server's .env file and apply on restart. ", + "Cosa significano": "What they mean", + "Pertinenza minima notizia–mercato": "Minimum news–market match", + "Finestra delle notizie": "News window", + "Come si uniscono Jev e prezzo": "How Jev and price are combined", + "media lineare": "linear average", + "media in log-odds": "log-odds average", + "Calibrazione di Jev (a, b)": "Jev calibration (a, b)", + "Chiamate a Jev per previsione": "Jev calls per forecast", + "Frazione di Kelly": "Kelly fraction", + "Previsioni automatiche": "Automatic forecasts", + "Sì": "Yes", + "No": "No", + "TypeSafe Jev": "TypeSafe Jev", + "Groq": "Groq", + "Gemini": "Gemini", + "Chiamate alle API a pagamento (Jev, Groq, Gemini) per giorno e per funzione, con limiti giornalieri. Oltre il limite le chiamate si fermano fino a mezzanotte: classificazione e riassunti passano alle alternative gratuite.": "Calls to paid APIs (Jev, Groq, Gemini) per day and per feature, with daily limits. Past the limit calls stop until midnight: classification and summaries switch to the free alternatives.", + "{0} h {1} min": "{0} h {1} min", + "Chiamate a Jev oggi": "Jev calls today", + "su {0} al giorno": "of {0} per day", + "nessun limite impostato": "no limit set", + "Spesa stimata oggi": "Estimated spend today", + "imposta i prezzi qui sotto": "set the prices below", + "nessun budget impostato": "no budget set", + "{0} del limite": "{0} of the limit", + "{0} del budget": "{0} of the budget", + "Oggi": "Today", + "si azzera tra {0}": "resets in {0}", + "Limite giornaliero raggiunto. ": "Daily limit reached. ", + "Previsioni, allerte e backtest sono in pausa fino a mezzanotte; classificazione e riassunti usano le alternative gratuite.": "Forecasts, alerts and backtests are paused until midnight; classification and summaries use the free alternatives.", + "Hai usato più del {0} di un limite giornaliero.": "You have used more than {0} of a daily limit.", + "Uso per giorno negli ultimi {0} giorni": "Usage per day over the last {0} days", + "Nessuna chiamata a pagamento in questo periodo.": "No paid calls in this period.", + "Misura": "Measure", + "Chiamate": "Calls", + "Token": "Tokens", + "Ultimi {0} giorni": "Last {0} days", + "Errori": "Errors", + "Token in": "Tokens in", + "Token out": "Tokens out", + "Quota": "Share", + "Per funzione e per servizio": "By feature and by service", + "Il costo è una stima: token (o chiamate) per i prezzi impostati qui sotto. Controlla sempre la fattura del fornitore.": "The cost is an estimate: tokens (or calls) times the prices set below. Always check the provider's invoice.", + "Funzione": "Feature", + "Servizio": "Service", + "Ancora nessuna chiamata registrata.": "No calls recorded yet.", + "I valori devono essere numeri maggiori o uguali a zero": "Values must be numbers greater than or equal to zero", + "Limiti e prezzi salvati": "Limits and prices saved", + "Limiti e prezzi": "Limits and prices", + "Solo un amministratore può modificarli": "Only an administrator can change them", + "0 = nessun limite. I prezzi sono in dollari per milione di token: prendili dal listino del tuo piano.": "0 = no limit. Prices are in dollars per million tokens: take them from your plan's price list.", + "Chiamate a Jev al giorno": "Jev calls per day", + "Budget giornaliero": "Daily budget", + "Jev: prezzo per chiamata": "Jev: price per call", + "Jev: token in": "Jev: tokens in", + "Jev: token out": "Jev: tokens out", + "Groq: token in": "Groq: tokens in", + "Groq: token out": "Groq: tokens out", + "Gemini: token in": "Gemini: tokens in", + "Gemini: token out": "Gemini: tokens out", + "alta": "high", + "media": "medium", + "bassa": "low", + "Venduta a mano": "Sold by hand", + "Altri esiti": "Other outcomes", + "Altri esiti ({0})": "Other outcomes ({0})", + "Prezzo storico non disponibile": "Historical price not available", + "Esito già scontato dal prezzo": "Outcome already priced in", + "Nessuna notizia di quei giorni": "No news from those days", + "Il vincitore non ha uno storico dei prezzi": "The winner has no price history", + "{0} quote {1}": "{0} {1} shares", + "{0} a non più di {1}": "{0} at no more than {1}", + "{0} a {1} o più": "{0} at {1} or more", + "{0} al meglio": "{0} at market", + "Prudente": "Prudent", + "Bilanciato": "Balanced", + "Aggressivo": "Aggressive", + "pt": "pts", + "{0} gg": "{0} d", + "{0} di {1}": "{0} of {1}", + "{0} su {1}": "{0} on {1}", + "su {0}": "of {0}", + " su {0}": " of {0}", + "{0} eliminata con {1}": "{0} deleted with {1}", + "{0} eliminata": "{0} deleted", + "Le notizie arrivano al prossimo aggiornamento.": "The news arrives at the next update.", + "{0} attivata": "{0} activated", + "{0} aggiornata": "{0} updated", +}; diff --git a/frontend/i18n.js b/frontend/i18n.js new file mode 100644 index 0000000..b1e5f5f --- /dev/null +++ b/frontend/i18n.js @@ -0,0 +1,37 @@ +// Interface language. The source strings are Italian; i18n-en.js maps each of them to English. +// t("Scade {0}", date) returns the string in the current language with the placeholders filled. +import EN from "./i18n-en.js"; + +const KEY = "lang"; + +function initial() { + try { + const saved = localStorage.getItem(KEY); + if (saved === "it" || saved === "en") return saved; + } catch { /* storage unavailable */ } + return (navigator.language || "").toLowerCase().startsWith("it") ? "it" : "en"; +} + +export const lang = initial(); +export const locale = lang === "it" ? "it-IT" : "en-GB"; +document.documentElement.lang = lang; + +// Strings asked in English that have no translation yet (checked by the tests of the UI) +const missing = new Set(); +window.__i18nMissing = missing; + +export function t(text, ...args) { + let out = text; + if (lang === "en" && typeof text === "string") { + const tr = EN[text]; + if (tr === undefined) missing.add(text); + else out = tr; + } + return args.length ? String(out).replace(/\{(\d+)\}/g, (m, i) => (args[+i] ?? "")) : out; +} + +/** Saves the choice and reloads: every view, label and number format follows the new language. */ +export function setLang(next) { + try { localStorage.setItem(KEY, next); } catch { /* the choice lasts for this page only */ } + window.location.reload(); +} diff --git a/frontend/index.html b/frontend/index.html index aed449d..c160666 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -55,6 +55,10 @@ +
+ + +
diff --git a/frontend/nav.js b/frontend/nav.js index 49aefd9..e891b34 100644 --- a/frontend/nav.js +++ b/frontend/nav.js @@ -1,30 +1,31 @@ // Navigation: one definition for the desktop sidebar, the mobile bottom bar and the "Altro" sheet. +import { t } from "./i18n.js"; import { h, icon } from "./ui.js"; // Sections grouped by what the user is doing: find signals, browse markets, read news, check results export const NAV = [ - { group: "Segnali", items: [ - { id: "opportunita", href: "#/opportunita", label: "Opportunità", icon: "up" }, - { id: "allerte", href: "#/allerte", label: "Allerte", icon: "bell" }, + { group: t("Segnali"), items: [ + { id: "opportunita", href: "#/opportunita", label: t("Opportunità"), icon: "up" }, + { id: "allerte", href: "#/allerte", label: t("Allerte"), icon: "bell" }, ] }, - { group: "Mercati", items: [ - { id: "mercati", href: "#/mercati", label: "Sì / No", icon: "toggle", short: "Mercati" }, - { id: "multi", href: "#/multi", label: "Più esiti", icon: "list" }, + { group: t("Mercati"), items: [ + { id: "mercati", href: "#/mercati", label: t("Sì / No"), icon: "toggle", short: t("Mercati") }, + { id: "multi", href: "#/multi", label: t("Più esiti"), icon: "list" }, ] }, - { group: "Notizie", items: [ - { id: "notizie", href: "#/notizie", label: "Notizie", icon: "news" }, + { group: t("Notizie"), items: [ + { id: "notizie", href: "#/notizie", label: t("Notizie"), icon: "news" }, ] }, - { group: "Risultati", items: [ - { id: "portafoglio", href: "#/portafoglio", label: "Portafoglio", icon: "wallet" }, - { id: "backtest", href: "#/backtest", label: "Backtest", icon: "history" }, - { id: "calibrazione", href: "#/calibrazione", label: "Calibrazione", icon: "target" }, + { group: t("Risultati"), items: [ + { id: "portafoglio", href: "#/portafoglio", label: t("Portafoglio"), icon: "wallet" }, + { id: "backtest", href: "#/backtest", label: t("Backtest"), icon: "history" }, + { id: "calibrazione", href: "#/calibrazione", label: t("Calibrazione"), icon: "target" }, ] }, ]; export const FOOTER_NAV = [ - { id: "impostazioni", href: "#/impostazioni", label: "Impostazioni", icon: "settings" }, - { id: "uso", href: "#/uso", label: "Uso e costi", icon: "gauge" }, - { id: "metodo", href: "#/metodo", label: "Come funziona", icon: "help" }, + { id: "impostazioni", href: "#/impostazioni", label: t("Impostazioni"), icon: "settings" }, + { id: "uso", href: "#/uso", label: t("Uso e costi"), icon: "gauge" }, + { id: "metodo", href: "#/metodo", label: t("Come funziona"), icon: "help" }, ]; // Mobile bottom bar: the destinations used most (at most 4, plus "Altro") @@ -48,7 +49,7 @@ export function renderNav({ user, onLogout }) { const userItem = { id: "account", href: "#/account", label: user.username, icon: "user" }; const logoutBtn = (cls) => { - const b = h("button", { class: cls, type: "button", title: "Esci" }, icon("logout"), h("span", { class: "nav-label" }, "Esci")); + const b = h("button", { class: cls, type: "button", title: t("Esci") }, icon("logout"), h("span", { class: "nav-label" }, t("Esci"))); b.addEventListener("click", onLogout); return b; }; @@ -63,7 +64,7 @@ export function renderNav({ user, onLogout }) { const bottom = document.getElementById("bottom-nav"); const items = allItems(); const moreBtn = h("button", { class: "bottom-item", type: "button", id: "btn-more", "aria-haspopup": "dialog", "aria-expanded": "false" }, - icon("more"), h("span", { class: "nav-label" }, "Altro")); + icon("more"), h("span", { class: "nav-label" }, t("Altro"))); bottom.replaceChildren(...BOTTOM.map((id) => link(items.find((i) => i.id === id), "bottom-item")), moreBtn); document.getElementById("sheet-nav").replaceChildren( @@ -71,7 +72,7 @@ export function renderNav({ user, onLogout }) { h("p", { class: "nav-group-label" }, g.group), h("ul", { role: "list" }, g.items.map((it) => h("li", {}, link(it, "nav-item")))))), h("div", { class: "nav-group" }, - h("p", { class: "nav-group-label" }, "Altro"), + h("p", { class: "nav-group-label" }, t("Altro")), h("ul", { role: "list" }, FOOTER_NAV.map((it) => h("li", {}, link(it, "nav-item"))), h("li", {}, link(userItem, "nav-item")), h("li", {}, logoutBtn("nav-item")))), ); @@ -92,7 +93,7 @@ export function setBadge(sectionId, count) { document.querySelectorAll(`[data-badge="${sectionId}"]`).forEach((el) => { el.hidden = !count; el.textContent = count > 99 ? "99+" : String(count || ""); - el.setAttribute("aria-label", count ? `${count} nuove` : ""); + el.setAttribute("aria-label", count ? t("{0} nuove", count) : ""); }); } @@ -138,7 +139,7 @@ export function setupCollapse() { const apply = (collapsed) => { document.documentElement.classList.toggle("nav-collapsed", collapsed); btn.setAttribute("aria-expanded", String(!collapsed)); - btn.setAttribute("aria-label", collapsed ? "Espandi il menu" : "Riduci il menu"); + btn.setAttribute("aria-label", collapsed ? t("Espandi il menu") : t("Riduci il menu")); btn.title = btn.getAttribute("aria-label"); }; let collapsed = false; diff --git a/frontend/strategy.js b/frontend/strategy.js index d6731f7..f6b1c01 100644 --- a/frontend/strategy.js +++ b/frontend/strategy.js @@ -1,21 +1,22 @@ // "Cosa fare": the plan built from the latest forecast — action, orders, price levels, reasons. +import { t } from "./i18n.js"; import { h, fmt, icon, infoTip } from "./ui.js"; const ACTIONS = { - BUY: { cls: "badge-good", icon: "up", label: "Compra" }, - WAIT: { cls: "badge-warning", icon: "pause", label: "Aspetta" }, - AVOID: { cls: "badge-critical", icon: "x", label: "Evita" }, - HOLD: { cls: "badge-accent", icon: "check", label: "Tieni" }, - SELL: { cls: "badge-warning", icon: "down", label: "Vendi" }, - NONE: { cls: "", icon: "minus", label: "Nessuna azione" }, + BUY: { cls: "badge-good", icon: "up", label: t("Compra") }, + WAIT: { cls: "badge-warning", icon: "pause", label: t("Aspetta") }, + AVOID: { cls: "badge-critical", icon: "x", label: t("Evita") }, + HOLD: { cls: "badge-accent", icon: "check", label: t("Tieni") }, + SELL: { cls: "badge-warning", icon: "down", label: t("Vendi") }, + NONE: { cls: "", icon: "minus", label: t("Nessuna azione") }, }; -const SIDE = { YES: "SÌ", NO: "NO" }; +const SIDE = { YES: t("SÌ"), NO: "NO" }; const CONFIDENCE = { alta: "badge-good", media: "", bassa: "badge-critical" }; export const STRATEGY_HELP = { - sell: "Il prezzo a cui incassare subito rende più che aspettare la risoluzione, tenendo conto delle commissioni e del rendimento che il preset chiede al capitale bloccato.", - levels: "Prezzi del SÌ a cui la decisione cambierebbe. La stima viene ricalcolata a ogni prezzo con lo stesso metodo della previsione.", - stop: "Niente stop-loss fisso: se il prezzo scende ma la stima non cambia, la quota è ancora più conveniente. Si vende quando una nuova previsione gira la stima o il prezzo raggiunge il valore stimato.", + sell: t("Il prezzo a cui incassare subito rende più che aspettare la risoluzione, tenendo conto delle commissioni e del rendimento che il preset chiede al capitale bloccato."), + levels: t("Prezzi del SÌ a cui la decisione cambierebbe. La stima viene ricalcolata a ogni prezzo con lo stesso metodo della previsione."), + stop: t("Niente stop-loss fisso: se il prezzo scende ma la stima non cambia, la quota è ancora più conveniente. Si vende quando una nuova previsione gira la stima o il prezzo raggiunge il valore stimato."), }; export function actionBadge(plan, big = false) { @@ -26,20 +27,21 @@ export function actionBadge(plan, big = false) { function orderLine(o) { const side = SIDE[o.side]; - const qty = o.shares ? `${fmt.shares(o.shares)} quote ` : ""; + // "12 quote SÌ" / "12 YES shares", or just the side when the quantity is not known + const what = o.shares ? t("{0} quote {1}", fmt.shares(o.shares), side) : side; if (o.type === "buy" && o.conditional) { - return [icon("pause"), h("span", {}, "Ordine in attesa: ", h("b", {}, `compra ${side} a ${fmt.cents(o.limit)} o meno`), - ". Conviene solo se il prezzo arriva lì.")]; + return [icon("pause"), h("span", {}, t("Ordine in attesa: "), h("b", {}, t("compra {0} a {1} o meno", side, fmt.cents(o.limit))), + t(". Conviene solo se il prezzo arriva lì."))]; } if (o.type === "buy") { - return [icon("up"), h("span", {}, "Acquisto con ordine limite: ", h("b", {}, `${qty}${side} a non più di ${fmt.cents(o.limit)}`), - o.usd != null ? ` · circa ${fmt.money(o.usd)}` : "")]; + return [icon("up"), h("span", {}, t("Acquisto con ordine limite: "), h("b", {}, t("{0} a non più di {1}", what, fmt.cents(o.limit))), + o.usd != null ? t(" · circa {0}", fmt.money(o.usd)) : "")]; } if (o.after_fill) { - return [icon("target"), h("span", {}, "Appena comprate: ", h("b", {}, `vendita limite a ${fmt.cents(o.limit)}`), - " (resta nel book finché il prezzo non ci arriva)")]; + return [icon("target"), h("span", {}, t("Appena comprate: "), h("b", {}, t("vendita limite a {0}", fmt.cents(o.limit))), + t(" (resta nel book finché il prezzo non ci arriva)"))]; } - return [icon("down"), h("span", {}, "Vendita: ", h("b", {}, o.limit != null ? `${qty}${side} a ${fmt.cents(o.limit)} o più` : `${qty}${side} al meglio`))]; + return [icon("down"), h("span", {}, t("Vendita: "), h("b", {}, o.limit != null ? t("{0} a {1} o più", what, fmt.cents(o.limit)) : t("{0} al meglio", what)))]; } /** Scale of YES prices: where buying YES or NO would pay, where the price is, where to sell. */ @@ -50,26 +52,26 @@ function priceLadder(plan, priceYes) { // The sale target is a price of the side held: on the YES scale for a NO position it is 1 − target const sellYes = lv.sell_above == null ? null : plan.side === "NO" ? 1 - lv.sell_above : lv.sell_above; const labels = []; - if (lv.buy_yes_below != null) labels.push(`compra SÌ sotto ${fmt.cents(lv.buy_yes_below)}`); - if (lv.buy_no_above != null) labels.push(`compra NO sopra ${fmt.cents(lv.buy_no_above)}`); - if (sellYes != null) labels.push(`vendi a ${fmt.cents(sellYes)}`); + if (lv.buy_yes_below != null) labels.push(t("compra SÌ sotto {0}", fmt.cents(lv.buy_yes_below))); + if (lv.buy_no_above != null) labels.push(t("compra NO sopra {0}", fmt.cents(lv.buy_no_above))); + if (sellYes != null) labels.push(t("vendi a {0}", fmt.cents(sellYes))); return h("div", { class: "ladder-wrap" }, - h("div", { class: "ladder", role: "img", "aria-label": `Prezzo del SÌ ${fmt.cents(priceYes)}: ${labels.join(", ")}` }, + h("div", { class: "ladder", role: "img", "aria-label": t("Prezzo del SÌ {0}: {1}", fmt.cents(priceYes), labels.join(", ")) }, lv.buy_yes_below != null ? h("span", { class: "zone yes", style: { left: "0", width: pos(lv.buy_yes_below) } }) : null, lv.buy_no_above != null ? h("span", { class: "zone no", style: { left: pos(lv.buy_no_above), right: "0" } }) : null, - sellYes != null ? h("span", { class: "tick sell", style: { left: pos(sellYes) } }, h("span", { class: "tick-label" }, `vendi ${fmt.cents(sellYes)}`)) : null, - h("span", { class: "tick now", style: { left: pos(priceYes) } }, h("span", { class: "tick-label" }, `ora ${fmt.cents(priceYes)}`))), - h("div", { class: "ladder-axis mono" }, h("span", {}, "0¢"), h("span", {}, "prezzo del SÌ"), h("span", {}, "100¢")), + sellYes != null ? h("span", { class: "tick sell", style: { left: pos(sellYes) } }, h("span", { class: "tick-label" }, t("vendi {0}", fmt.cents(sellYes)))) : null, + h("span", { class: "tick now", style: { left: pos(priceYes) } }, h("span", { class: "tick-label" }, t("ora {0}", fmt.cents(priceYes))))), + h("div", { class: "ladder-axis mono" }, h("span", {}, "0¢"), h("span", {}, t("prezzo del SÌ")), h("span", {}, "100¢")), h("div", { class: "ladder-legend" }, - lv.buy_yes_below != null ? h("span", {}, h("span", { class: "key zone-yes" }), `Compra SÌ sotto ${fmt.cents(lv.buy_yes_below)}`) : null, - lv.buy_no_above != null ? h("span", {}, h("span", { class: "key zone-no" }), `Compra NO se il SÌ sale sopra ${fmt.cents(lv.buy_no_above)}`) : null, - sellYes != null ? h("span", {}, h("span", { class: "key tick-sell" }), `Vendi a ${fmt.cents(lv.sell_above)} (${SIDE[plan.side] || "lato comprato"})`) : null, + lv.buy_yes_below != null ? h("span", {}, h("span", { class: "key zone-yes" }), t("Compra SÌ sotto {0}", fmt.cents(lv.buy_yes_below))) : null, + lv.buy_no_above != null ? h("span", {}, h("span", { class: "key zone-no" }), t("Compra NO se il SÌ sale sopra {0}", fmt.cents(lv.buy_no_above))) : null, + sellYes != null ? h("span", {}, h("span", { class: "key tick-sell" }), t("Vendi a {0} ({1})", fmt.cents(lv.sell_above), SIDE[plan.side] || t("lato comprato"))) : null, infoTip(STRATEGY_HELP.levels))); } function reasonList(items, good) { - if (!items.length) return h("p", { class: "muted small" }, good ? "Nessun motivo forte a favore." : "Nessun motivo contro rilevante."); - return h("ul", { class: `plan-reasons ${good ? "pro" : "con"}` }, items.map((t) => h("li", {}, icon(good ? "check" : "x"), h("span", {}, t)))); + if (!items.length) return h("p", { class: "muted small" }, good ? t("Nessun motivo forte a favore.") : t("Nessun motivo contro rilevante.")); + return h("ul", { class: `plan-reasons ${good ? "pro" : "con"}` }, items.map((x) => h("li", {}, icon(good ? "check" : "x"), h("span", {}, x)))); } /** The whole plan, for the top of the "Cosa fare" card. `priceYes`: current YES price of the market. */ @@ -81,16 +83,16 @@ export function strategySection(plan, priceYes) { h("div", { class: "plan-title" }, plan.title !== actionBadge(plan).textContent ? h("b", {}, plan.title) : null, h("p", { class: plan.title !== actionBadge(plan).textContent ? "secondary" : "" }, plan.summary)), h("span", { class: `badge ${CONFIDENCE[plan.confidence] || ""}`, title: plan.confidence_why.join(" · ") }, - `fiducia ${plan.confidence}`)), + t("fiducia {0}", t(plan.confidence)))), plan.orders.length ? h("ul", { class: "plan-orders" }, plan.orders.map((o) => h("li", {}, ...orderLine(o)))) : null, priceLadder(plan, priceYes), h("div", { class: "plan-why" }, - h("div", {}, h("h3", {}, "Perché sì"), reasonList(plan.pros, true)), - h("div", {}, h("h3", {}, "Perché no"), reasonList(plan.cons, false))), + h("div", {}, h("h3", {}, t("Perché sì")), reasonList(plan.pros, true)), + h("div", {}, h("h3", {}, t("Perché no")), reasonList(plan.cons, false))), plan.exit.length ? h("div", { class: "plan-exit" }, - h("h3", {}, "Piano d'uscita", infoTip(STRATEGY_HELP.stop)), - h("ul", { class: "bullets small" }, plan.exit.map((t) => h("li", {}, t)))) : null, - h("p", { class: "muted small" }, `Fiducia ${plan.confidence}: ${plan.confidence_why.join(", ")}.`), + h("h3", {}, t("Piano d'uscita"), infoTip(STRATEGY_HELP.stop)), + h("ul", { class: "bullets small" }, plan.exit.map((x) => h("li", {}, x)))) : null, + h("p", { class: "muted small" }, t("Fiducia {0}: {1}.", t(plan.confidence), plan.confidence_why.join(", "))), ); } @@ -104,13 +106,13 @@ export function planLine(ev) { let badge, text; if (ev.verdict === "GO" || ev.verdict === "SMALL") { badge = actionBadge({ action: "BUY", side: ev.side }); - text = `${side} a non più di ${fmt.cents(ev.limit_price)} · circa ${fmt.money(ev.outlay)}`; + text = t("{0} a non più di {1} · circa {2}", side, fmt.cents(ev.limit_price), fmt.money(ev.outlay)); } else if (blocking.some((r) => STRUCTURAL.has(r.code))) { badge = actionBadge({ action: "AVOID" }); text = blocking.find((r) => STRUCTURAL.has(r.code)).text; } else if (blocking.length) { badge = actionBadge({ action: "WAIT" }); - text = `al prezzo di allora costi e incertezza si mangiavano il vantaggio (limite ${fmt.cents(ev.limit_price)})`; + text = t("al prezzo di allora costi e incertezza si mangiavano il vantaggio (limite {0})", fmt.cents(ev.limit_price)); } else { return null; } diff --git a/frontend/styles.css b/frontend/styles.css index ecffbcb..7a16c3f 100644 --- a/frontend/styles.css +++ b/frontend/styles.css @@ -807,8 +807,8 @@ th.num .th-sort { flex-direction: row-reverse; } /* ---------- Più esiti ---------- */ .multi-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); gap: 16px; } @media (max-width: 420px) { .multi-grid { grid-template-columns: minmax(0, 1fr); } } -.multi-card { display: grid; gap: 10px; align-content: start; } -.multi-title { font-size: 1rem; line-height: 1.35; } +.multi-card { display: grid; grid-template-columns: minmax(0, 1fr); gap: 10px; align-content: start; } +.multi-title { font-size: 1rem; line-height: 1.35; overflow-wrap: anywhere; } .multi-title a { color: var(--text-primary); } .multi-foot { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; margin-top: 2px; } .multi-foot .btn { margin-left: auto; } @@ -829,7 +829,8 @@ th.num .th-sort { flex-direction: row-reverse; } .dist-track .pmark { width: 12px; height: 12px; } .dist-track .pmark.jev { width: 9px; height: 9px; } .opps-multi { margin-top: 24px; } -.signal-line { display: inline-flex; flex-wrap: wrap; align-items: center; gap: 6px 8px; } +.signal-line { display: inline-flex; flex-wrap: wrap; align-items: center; gap: 6px 8px; max-width: 100%; } +.signal-line .badge { white-space: normal; max-width: 100%; } /* ---------- Strategy ("Cosa fare") ---------- */ .plan { display: grid; gap: 14px; padding-bottom: 16px; margin-bottom: 16px; border-bottom: 1px solid var(--border); } @@ -867,3 +868,15 @@ th.num .th-sort { flex-direction: row-reverse; } .plan-head { grid-template-columns: minmax(0, 1fr); } } .opp-plan { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-top: 10px; } + +/* ---------- Language switch (IT / EN, next to the theme button) ---------- */ +.lang-switch .seg { min-height: 38px; padding: 0 10px; font-weight: 600; font-size: 0.8rem; letter-spacing: 0.02em; } +@media (pointer: coarse) { .lang-switch .seg { min-height: 44px; } } +/* Phones: brand, Update, IT/EN and theme must fit in one row */ +@media (max-width: 480px) { + .topbar-inner { gap: 8px; } + .brand-mobile { min-width: 0; overflow: hidden; } + .lang-switch .seg { padding: 0 8px; } +} +/* Too narrow for the name next to the buttons: the logo alone (the link keeps its aria-label) */ +@media (max-width: 400px) { .brand-mobile .brand-text { display: none; } } diff --git a/frontend/ui.js b/frontend/ui.js index 8d82b98..7270ed4 100644 --- a/frontend/ui.js +++ b/frontend/ui.js @@ -1,3 +1,4 @@ +import { t, lang, locale } from "./i18n.js"; // Small DOM, formatting and API helpers shared by every view. // ---------- DOM ---------- @@ -121,28 +122,36 @@ export function hydrateIcons(root = document) { } // ---------- Formatting ---------- -const pctFmt = new Intl.NumberFormat("it-IT", { style: "percent", maximumFractionDigits: 1 }); -const centFmt = new Intl.NumberFormat("it-IT", { maximumFractionDigits: 1 }); -const usdFmt = new Intl.NumberFormat("it-IT", { style: "currency", currency: "USD", notation: "compact", maximumFractionDigits: 1 }); -const intFmt = new Intl.NumberFormat("it-IT"); -const moneyFmt = new Intl.NumberFormat("it-IT", { minimumFractionDigits: 2, maximumFractionDigits: 2 }); -const sharesFmt = new Intl.NumberFormat("it-IT", { maximumFractionDigits: 1 }); -const numFmt = new Intl.NumberFormat("it-IT", { minimumFractionDigits: 3, maximumFractionDigits: 3 }); -const rtf = new Intl.RelativeTimeFormat("it", { numeric: "auto" }); +const pctFmt = new Intl.NumberFormat(locale, { style: "percent", maximumFractionDigits: 1 }); +const centFmt = new Intl.NumberFormat(locale, { maximumFractionDigits: 1 }); +const usdFmt = new Intl.NumberFormat(locale, { style: "currency", currency: "USD", currencyDisplay: "narrowSymbol", notation: "compact", maximumFractionDigits: 1 }); +const intFmt = new Intl.NumberFormat(locale); +const moneyFmt = new Intl.NumberFormat(locale, { minimumFractionDigits: 2, maximumFractionDigits: 2 }); +const sharesFmt = new Intl.NumberFormat(locale, { maximumFractionDigits: 1 }); +const numFmt = new Intl.NumberFormat(locale, { minimumFractionDigits: 3, maximumFractionDigits: 3 }); +const dec1Fmt = new Intl.NumberFormat(locale, { minimumFractionDigits: 1, maximumFractionDigits: 1 }); +// Dollar amounts: "41,02 $" in Italian, "$41.02" in English +const dollars = (text) => (lang === "it" ? `${text} $` : `$${text}`); +const rtf = new Intl.RelativeTimeFormat(lang, { numeric: "auto" }); export const fmt = { pct: (p) => (p == null ? "–" : pctFmt.format(p)), // Polymarket quotes a YES share in cents: price 0.35 = 35¢ = 35% implied probability cents: (p) => (p == null ? "–" : `${centFmt.format(p * 100)}¢`), - pts: (edge) => (edge == null ? "–" : `${edge > 0 ? "+" : edge < 0 ? "−" : ""}${Math.abs(edge * 100).toFixed(1).replace(".", ",")} pt`), + pts: (edge) => (edge == null ? "–" : `${edge > 0 ? "+" : edge < 0 ? "−" : ""}${dec1Fmt.format(Math.abs(edge * 100))} ${t("pt")}`), + /** Plain decimal in the interface language: 0.25 → "0,25" in Italian, "0.25" in English. */ + dec: (v, digits) => (v == null ? "–" : new Intl.NumberFormat(locale, digits == null + ? { maximumFractionDigits: 4 } : { minimumFractionDigits: digits, maximumFractionDigits: digits }).format(v)), usd: (v) => (v == null ? "–" : usdFmt.format(v)), - money: (v) => (v == null ? "–" : `${moneyFmt.format(v)} $`), - signedMoney: (v) => (v == null ? "–" : `${v > 0 ? "+" : v < 0 ? "−" : ""}${moneyFmt.format(Math.abs(v))} $`), + /** "41,02 $" in Italian, "$41.02" in English, for an already formatted number */ + dollars: (text) => dollars(text), + money: (v) => (v == null ? "–" : dollars(moneyFmt.format(v))), + signedMoney: (v) => (v == null ? "–" : `${v > 0 ? "+" : v < 0 ? "−" : ""}${dollars(moneyFmt.format(Math.abs(v)))}`), shares: (v) => (v == null ? "–" : sharesFmt.format(v)), int: (v) => (v == null ? "–" : intFmt.format(v)), num3: (v) => (v == null ? "–" : numFmt.format(v)), - date: (d) => (d ? new Date(d).toLocaleDateString("it-IT", { day: "numeric", month: "short", year: "numeric" }) : "–"), - dateTime: (d) => (d ? new Date(d).toLocaleString("it-IT", { day: "numeric", month: "short", hour: "2-digit", minute: "2-digit" }) : "–"), + date: (d) => (d ? new Date(d).toLocaleDateString(locale, { day: "numeric", month: "short", year: "numeric" }) : "–"), + dateTime: (d) => (d ? new Date(d).toLocaleString(locale, { day: "numeric", month: "short", hour: "2-digit", minute: "2-digit" }) : "–"), /** "1 notizia" / "3 notizie" */ count: (n, one, many) => `${intFmt.format(n)} ${n === 1 ? one : many}`, ago(d) { @@ -150,7 +159,7 @@ export const fmt = { const diff = (new Date(d).getTime() - Date.now()) / 1000; const units = [["year", 31536000], ["month", 2592000], ["week", 604800], ["day", 86400], ["hour", 3600], ["minute", 60]]; for (const [unit, secs] of units) if (Math.abs(diff) >= secs) return rtf.format(Math.round(diff / secs), unit); - return "adesso"; + return t("adesso"); }, }; @@ -178,7 +187,8 @@ export async function api(path, { method = "GET", params, body, handle401 = true for (const [k, v] of Object.entries(params || {})) { if (v !== undefined && v !== null && v !== "") url.searchParams.set(k, v); } - const headers = { Accept: "application/json" }; + // X-Lang: texts the server writes (plans, reasons, errors) come back in the interface language + const headers = { Accept: "application/json", "X-Lang": lang }; if (body !== undefined) headers["Content-Type"] = "application/json"; if (method !== "GET" && csrfToken) headers["X-CSRF-Token"] = csrfToken; let res; @@ -188,12 +198,12 @@ export async function api(path, { method = "GET", params, body, handle401 = true body: body !== undefined ? JSON.stringify(body) : undefined, }); } catch { - throw new ApiError(0, "Impossibile contattare il server. Controlla la connessione e riprova."); + throw new ApiError(0, t("Impossibile contattare il server. Controlla la connessione e riprova.")); } const data = res.status === 204 ? null : await res.json().catch(() => null); if (!res.ok) { const detail = data && data.detail; - const message = typeof detail === "string" ? detail : Array.isArray(detail) ? detail.map((d) => d.msg).join("; ") : `Errore ${res.status}`; + const message = typeof detail === "string" ? detail : Array.isArray(detail) ? detail.map((d) => d.msg).join("; ") : t("Errore {0}", res.status); if (res.status === 401 && handle401) window.dispatchEvent(new CustomEvent("auth:required")); throw new ApiError(res.status, message); } @@ -250,9 +260,9 @@ export function ttRows(title, rows) { // ---------- Shared components ---------- const SIGNALS = { - BUY_YES: { cls: "badge-good", icon: "up", label: "Compra SÌ" }, - BUY_NO: { cls: "badge-critical", icon: "down", label: "Compra NO" }, - HOLD: { cls: "", icon: "pause", label: "Attendi" }, + BUY_YES: { cls: "badge-good", icon: "up", label: t("Compra SÌ") }, + BUY_NO: { cls: "badge-critical", icon: "down", label: t("Compra NO") }, + HOLD: { cls: "", icon: "pause", label: t("Attendi") }, }; export function signalBadge(signal) { @@ -261,27 +271,27 @@ export function signalBadge(signal) { } const IMPACTS = { - raises_yes: { cls: "badge-good", icon: "arrowUp", label: "Favorisce SÌ" }, - lowers_yes: { cls: "badge-critical", icon: "arrowDown", label: "Favorisce NO" }, - neutral: { cls: "", icon: "minus", label: "Neutra" }, + raises_yes: { cls: "badge-good", icon: "arrowUp", label: t("Favorisce SÌ") }, + lowers_yes: { cls: "badge-critical", icon: "arrowDown", label: t("Favorisce NO") }, + neutral: { cls: "", icon: "minus", label: t("Neutra") }, }; export function impactBadge(impact) { - if (!impact) return h("span", { class: "badge badge-outline" }, "Non ancora valutata"); + if (!impact) return h("span", { class: "badge badge-outline" }, t("Non ancora valutata")); const s = IMPACTS[impact] || IMPACTS.neutral; return h("span", { class: `badge ${s.cls}` }, icon(s.icon), s.label); } export function marketStateBadge(market) { - if (market.resolved_yes === true) return h("span", { class: "badge badge-good" }, icon("check"), "Risolto SÌ"); - if (market.resolved_yes === false) return h("span", { class: "badge badge-critical" }, icon("x"), "Risolto NO"); - if (market.closed) return h("span", { class: "badge" }, "Chiuso"); - return h("span", { class: "badge badge-outline" }, "Aperto"); + if (market.resolved_yes === true) return h("span", { class: "badge badge-good" }, icon("check"), t("Risolto SÌ")); + if (market.resolved_yes === false) return h("span", { class: "badge badge-critical" }, icon("x"), t("Risolto NO")); + if (market.closed) return h("span", { class: "badge" }, t("Chiuso")); + return h("span", { class: "badge badge-outline" }, t("Aperto")); } export function meter(value, label) { const v = Math.max(0, Math.min(1, value ?? 0)); - return h("div", { class: "meter", role: "meter", "aria-valuemin": "0", "aria-valuemax": "1", "aria-valuenow": v.toFixed(2), "aria-label": label || "valore" }, + return h("div", { class: "meter", role: "meter", "aria-valuemin": "0", "aria-valuemax": "1", "aria-valuenow": v.toFixed(2), "aria-label": label || t("valore") }, h("div", { class: "meter-track" }, h("div", { class: "meter-fill", style: { width: `${v * 100}%` } })), h("span", { class: "meter-value" }, fmt.pct(v)), ); @@ -305,29 +315,29 @@ export function skeleton(n = 3) { // ---------- Labels ---------- export const CATEGORY_LABELS = { - Politics: "Politica", Economy: "Economia", Crypto: "Crypto", Technology: "Tecnologia", - "Foreign Affairs": "Esteri", Science: "Scienza e salute", Sports: "Sport", Culture: "Cultura", + Politics: t("Politica"), Economy: t("Economia"), Crypto: t("Crypto"), Technology: t("Tecnologia"), + "Foreign Affairs": t("Esteri"), Science: t("Scienza e salute"), Sports: t("Sport"), Culture: t("Cultura"), }; export const REGION_LABELS = { - "North America": "Nord America", Europe: "Europa", "Middle East & Africa": "Medio Oriente e Africa", - "Asia-Pacific": "Asia-Pacifico", "Latin America": "America Latina", Global: "Globale", + "North America": t("Nord America"), Europe: t("Europa"), "Middle East & Africa": t("Medio Oriente e Africa"), + "Asia-Pacific": "Asia-Pacifico", "Latin America": t("America Latina"), Global: t("Globale"), }; // ---------- Glossary ---------- export const GLOSSARY = { - price: "Prezzo di una quota SÌ su Polymarket, in centesimi. Una quota paga 1 $ se l'evento accade: 35¢ equivale a una probabilità implicita del 35%.", - jev: "Probabilità che il mercato si risolva SÌ secondo Jev, calcolata leggendo le regole del mercato e le notizie collegate. Jev non vede il prezzo.", - evidence: "Quanto le notizie collegate dicono qualcosa di concreto sull'esito, da 0% (nulla di rilevante) a 100% (informazione decisiva). Stabilisce quanto pesa la stima di Jev.", - weight: "Peso della stima di Jev nella probabilità finale: peso massimo × forza delle evidenze. Il resto del peso va al prezzo di mercato.", - blended: "Probabilità finale usata per il segnale: una media pesata tra la stima di Jev e il prezzo di mercato.", - edge: "Differenza tra la probabilità blended e il prezzo, in punti percentuali. Positivo: il SÌ sembra sottovalutato. Negativo: il NO sembra sottovalutato.", - kelly: "Quota del capitale da puntare secondo il criterio di Kelly, ridotta per prudenza (Kelly frazionario). È un'indicazione, non un obbligo.", - brier: "Errore quadratico medio tra probabilità prevista ed esito reale (1 se SÌ, 0 se NO). Più basso è meglio; dire sempre 50% vale 0,25.", - relevance: "Quanto la notizia può cambiare la probabilità di un evento futuro verificabile su cui si scommette (elezioni, tassi, conflitti, sentenze, prezzi, partite).", + price: t("Prezzo di una quota SÌ su Polymarket, in centesimi. Una quota paga 1 $ se l'evento accade: 35¢ equivale a una probabilità implicita del 35%."), + jev: t("Probabilità che il mercato si risolva SÌ secondo Jev, calcolata leggendo le regole del mercato e le notizie collegate. Jev non vede il prezzo."), + evidence: t("Quanto le notizie collegate dicono qualcosa di concreto sull'esito, da 0% (nulla di rilevante) a 100% (informazione decisiva). Stabilisce quanto pesa la stima di Jev."), + weight: t("Peso della stima di Jev nella probabilità finale: peso massimo × forza delle evidenze. Il resto del peso va al prezzo di mercato."), + blended: t("Probabilità finale usata per il segnale: una media pesata tra la stima di Jev e il prezzo di mercato."), + edge: t("Differenza tra la probabilità blended e il prezzo, in punti percentuali. Positivo: il SÌ sembra sottovalutato. Negativo: il NO sembra sottovalutato."), + kelly: t("Quota del capitale da puntare secondo il criterio di Kelly, ridotta per prudenza (Kelly frazionario). È un'indicazione, non un obbligo."), + brier: t("Errore quadratico medio tra probabilità prevista ed esito reale (1 se SÌ, 0 se NO). Più basso è meglio; dire sempre 50% vale 0,25."), + relevance: t("Quanto la notizia può cambiare la probabilità di un evento futuro verificabile su cui si scommette (elezioni, tassi, conflitti, sentenze, prezzi, partite)."), }; /** Small focusable (i) button showing a definition on hover and focus. */ -export function infoTip(text, label = "Che cos'è?") { +export function infoTip(text, label = t("Che cos'è?")) { const btn = h("button", { class: "info-tip", type: "button", "aria-label": `${label} ${text}` }, "i"); withTooltip(btn, () => text); btn.addEventListener("click", (e) => e.preventDefault()); diff --git a/frontend/views/alerts.js b/frontend/views/alerts.js index 46c8f1d..5f256da 100644 --- a/frontend/views/alerts.js +++ b/frontend/views/alerts.js @@ -1,12 +1,13 @@ // Allerte: fresh news evaluated right away, Telegram notifications and the price afterwards. +import { t } from "../i18n.js"; import { h, api, fmt, toast, icon, statTile, emptyState, infoTip, externalLink, CATEGORY_LABELS } from "../ui.js"; import { verdictBadge } from "../economics.js"; -const CHECKPOINTS = [["15m", "15 min"], ["1h", "1 ora"], ["6h", "6 ore"], ["24h", "24 ore"]]; +const CHECKPOINTS = [["15m", t("15 min")], ["1h", t("1 ora")], ["6h", t("6 ore")], ["24h", t("24 ore")]]; const filters = { kind: "opportunities" }; const moveCls = (m) => (m == null ? "muted" : m > 0 ? "pos" : m < 0 ? "neg" : ""); -const sideLabel = (side) => (side === "YES" ? "SÌ" : side === "NO" ? "NO" : "–"); +const sideLabel = (side) => (side === "YES" ? t("SÌ") : side === "NO" ? "NO" : "–"); export async function viewAlerts(ctx) { const admin = ctx.isAdmin(); @@ -21,12 +22,12 @@ export async function viewAlerts(ctx) { const items = await api("/alerts", { params: { kind: filters.kind, limit: 100 } }); list.replaceChildren(...(items.length ? items.map(alertCard) : [emptyList(settings)])); } catch (e) { - list.replaceChildren(emptyState("Impossibile caricare le allerte", e.message)); + list.replaceChildren(emptyState(t("Impossibile caricare le allerte"), e.message)); } }; - const kindSwitch = h("div", { class: "segmented", role: "group", "aria-label": "Quali allerte" }, - [["opportunities", "Solo opportunità"], ["all", "Tutte le valutazioni"]].map(([value, label]) => { + const kindSwitch = h("div", { class: "segmented", role: "group", "aria-label": t("Quali allerte") }, + [["opportunities", t("Solo opportunità")], ["all", t("Tutte le valutazioni")]].map(([value, label]) => { const b = h("button", { type: "button", class: "seg", "aria-pressed": String(filters.kind === value) }, label); b.addEventListener("click", () => { filters.kind = value; @@ -38,15 +39,15 @@ export async function viewAlerts(ctx) { let runBtn = null; if (admin) { - runBtn = h("button", { class: "btn btn-ghost", type: "button", title: "Controlla subito le notizie collegate dall'ultimo controllo" }, - h("span", { class: "spinner", "aria-hidden": "true" }), icon("refresh"), "Controlla ora"); + runBtn = h("button", { class: "btn btn-ghost", type: "button", title: t("Controlla subito le notizie collegate dall'ultimo controllo") }, + h("span", { class: "spinner", "aria-hidden": "true" }), icon("refresh"), t("Controlla ora")); runBtn.addEventListener("click", async () => { ctx.setBusy(runBtn, true); try { const r = await api("/alerts/run", { method: "POST" }); toast(r.evaluated - ? `${fmt.count(r.evaluated, "mercato valutato", "mercati valutati")}, ${fmt.count(r.opportunities, "opportunità", "opportunità")}.` - : r.triggers ? "Notizie trovate, ma i mercati sono in pausa o il limite giornaliero è raggiunto." : "Nessuna notizia nuova da valutare."); + ? `${fmt.count(r.evaluated, t("mercato valutato"), t("mercati valutati"))}, ${fmt.count(r.opportunities, t("opportunità"), t("opportunità"))}.` + : r.triggers ? t("Notizie trovate, ma i mercati sono in pausa o il limite giornaliero è raggiunto.") : t("Nessuna notizia nuova da valutare.")); ctx.rerender(); } catch (e) { toast(e.message, { error: true }); @@ -57,12 +58,12 @@ export async function viewAlerts(ctx) { await loadList(); return h("div", {}, - ctx.pageHead("Allerte", - "Quando esce una notizia importante per un mercato, Jev lo valuta subito. Se conviene arriva una notifica su Telegram; poi si misura se il prezzo si è mosso nella direzione prevista.", + ctx.pageHead(t("Allerte"), + t("Quando esce una notizia importante per un mercato, Jev lo valuta subito. Se conviene arriva una notifica su Telegram; poi si misura se il prezzo si è mosso nella direzione prevista."), runBtn), setupNotes(settings), resultsCard(summary), - h("div", { class: "section-head" }, h("h2", {}, "Ultime allerte"), kindSwitch), + h("div", { class: "section-head" }, h("h2", {}, t("Ultime allerte")), kindSwitch), list, settingsCard(ctx, settings, categories), ); @@ -70,12 +71,12 @@ export async function viewAlerts(ctx) { function setupNotes(s) { const notes = []; - if (!s.jev_enabled) notes.push("Serve TYPESAFE_API_KEY: le allerte usano Jev per valutare il mercato."); - if (!s.enabled) notes.push("Le allerte sono disattivate (vedi le impostazioni in fondo)."); - if (s.enabled && !s.telegram_configured) notes.push("Telegram non è configurato: le allerte compaiono qui, ma non arrivano notifiche. Imposta TELEGRAM_BOT_TOKEN e TELEGRAM_CHAT_ID nel file .env."); + if (!s.jev_enabled) notes.push(t("Serve TYPESAFE_API_KEY: le allerte usano Jev per valutare il mercato.")); + if (!s.enabled) notes.push(t("Le allerte sono disattivate (vedi le impostazioni in fondo).")); + if (s.enabled && !s.telegram_configured) notes.push(t("Telegram non è configurato: le allerte compaiono qui, ma non arrivano notifiche. Imposta TELEGRAM_BOT_TOKEN e TELEGRAM_CHAT_ID nel file .env.")); const cadence = s.scan_minutes > 0 && s.scan_minutes < s.ingest_minutes - ? `Le fonti vengono controllate ogni ${s.scan_minutes} minuti.` - : `Le fonti vengono controllate ogni ${s.ingest_minutes} minuti, con la raccolta completa.`; + ? t("Le fonti vengono controllate ogni {0} minuti.", s.scan_minutes) + : t("Le fonti vengono controllate ogni {0} minuti, con la raccolta completa.", s.ingest_minutes); return h("div", { class: "notice", role: "note" }, icon(notes.length ? "alert" : "check"), h("div", {}, ...notes.map((n) => h("p", {}, n)), h("p", { class: "muted small" }, cadence)), @@ -87,7 +88,7 @@ function resultsCard(s) { const rows = CHECKPOINTS.map(([key, label]) => { const m = s.moves[key]; return h("tr", {}, - h("th", { scope: "row" }, `Dopo ${label}`), + h("th", { scope: "row" }, t("Dopo {0}", label)), h("td", { class: "num" }, fmt.int(m.count)), h("td", { class: `num ${moveCls(m.avg_move)}` }, m.avg_move == null ? "–" : fmt.pts(m.avg_move)), h("td", { class: "num" }, m.share_favorable == null ? "–" : fmt.pct(m.share_favorable)), @@ -95,42 +96,42 @@ function resultsCard(s) { }); return h("section", { class: "card", "aria-labelledby": "h-alert-results", style: { marginBottom: "16px" } }, h("div", { class: "card-head" }, - h("h2", { id: "h-alert-results" }, `Risultati degli ultimi ${s.days} giorni`), - infoTip("Movimento a favore: di quanti punti il prezzo si è spostato nella direzione consigliata dopo l'allerta. Positivo vuol dire che l'allerta è arrivata prima del mercato. Servono decine di allerte perché i numeri dicano qualcosa."), + h("h2", { id: "h-alert-results" }, t("Risultati degli ultimi {0} giorni", s.days)), + infoTip(t("Movimento a favore: di quanti punti il prezzo si è spostato nella direzione consigliata dopo l'allerta. Positivo vuol dire che l'allerta è arrivata prima del mercato. Servono decine di allerte perché i numeri dicano qualcosa.")), ), h("div", { class: "kpis" }, - statTile("Opportunità", fmt.int(s.opportunities), `su ${fmt.count(s.evaluated, "valutazione", "valutazioni")}`), - statTile("Notificate", fmt.int(s.notified), "su Telegram"), - statTile("A favore dopo 1 ora", oneHour.share_favorable == null ? "–" : fmt.pct(oneHour.share_favorable), - oneHour.avg_move == null ? "nessun dato ancora" : `media ${fmt.pts(oneHour.avg_move)}`), - statTile("Chiamate Jev (24 ore)", `${fmt.int(s.calls_last_24h)} / ${fmt.int(s.daily_budget)}`, "limite giornaliero delle allerte"), + statTile(t("Opportunità"), fmt.int(s.opportunities), t("su {0}", fmt.count(s.evaluated, t("valutazione"), t("valutazioni")))), + statTile(t("Notificate"), fmt.int(s.notified), t("su Telegram")), + statTile(t("A favore dopo 1 ora"), oneHour.share_favorable == null ? "–" : fmt.pct(oneHour.share_favorable), + oneHour.avg_move == null ? t("nessun dato ancora") : t("media {0}", fmt.pts(oneHour.avg_move))), + statTile(t("Chiamate Jev (24 ore)"), `${fmt.int(s.calls_last_24h)} / ${fmt.int(s.daily_budget)}`, t("limite giornaliero delle allerte")), ), h("div", { class: "table-wrap" }, h("table", { class: "compact-table" }, - h("thead", {}, h("tr", {}, h("th", { scope: "col" }, "Quando"), h("th", { scope: "col", class: "num" }, "Allerte"), - h("th", { scope: "col", class: "num" }, "Media a favore"), h("th", { scope: "col", class: "num" }, "A favore"))), + h("thead", {}, h("tr", {}, h("th", { scope: "col" }, t("Quando")), h("th", { scope: "col", class: "num" }, t("Allerte")), + h("th", { scope: "col", class: "num" }, t("Media a favore")), h("th", { scope: "col", class: "num" }, t("A favore")))), h("tbody", {}, rows), )), s.resolved ? h("p", { class: "muted small", style: { marginTop: "8px" } }, - `Mercati già risolti: ${fmt.int(s.won)} su ${fmt.int(s.resolved)} nella direzione dell'allerta.`) : null, + t("Mercati già risolti: {0} su {1} nella direzione dell'allerta.", fmt.int(s.won), fmt.int(s.resolved))) : null, s.clv?.n ? h("p", { class: "muted small", style: { marginTop: "4px" } }, - `Fino alla chiusura (${fmt.count(s.clv.n, "mercato chiuso", "mercati chiusi")}): in media ${fmt.pts(s.clv.avg)} nella direzione dell'allerta, a favore nel ${fmt.pct(s.clv.share_positive)} dei casi. `, - "È il segnale più affidabile che le allerte anticipano davvero il mercato.") : null, + t("Fino alla chiusura ({0}): in media {1} nella direzione dell'allerta, a favore nel {2} dei casi. ", fmt.count(s.clv.n, t("mercato chiuso"), t("mercati chiusi")), fmt.pts(s.clv.avg), fmt.pct(s.clv.share_positive)), + t("È il segnale più affidabile che le allerte anticipano davvero il mercato.")) : null, ); } function emptyList(s) { if (filters.kind === "opportunities") { - return emptyState("Nessuna opportunità ancora", - "Le allerte partono quando una notizia fresca e pertinente viene collegata a un mercato e Jev dice che conviene scommettere. Guarda «Tutte le valutazioni» per vedere anche i casi in cui non conveniva."); + return emptyState(t("Nessuna opportunità ancora"), + t("Le allerte partono quando una notizia fresca e pertinente viene collegata a un mercato e Jev dice che conviene scommettere. Guarda «Tutte le valutazioni» per vedere anche i casi in cui non conveniva.")); } - return emptyState("Nessuna valutazione ancora", s.enabled ? "Appena arriva una notizia importante per un mercato, compare qui." : "Le allerte sono disattivate."); + return emptyState(t("Nessuna valutazione ancora"), s.enabled ? t("Appena arriva una notizia importante per un mercato, compare qui.") : t("Le allerte sono disattivate.")); } function notifyState(a) { - if (a.notified_at) return h("span", { class: "muted small", title: fmt.dateTime(a.notified_at) }, icon("check"), " Notificata"); - if (a.notify_error) return h("span", { class: "small neg", title: a.notify_error }, icon("alert"), " Notifica non riuscita"); - return a.opportunity ? h("span", { class: "muted small" }, "Non notificata") : null; + if (a.notified_at) return h("span", { class: "muted small", title: fmt.dateTime(a.notified_at) }, icon("check"), t(" Notificata")); + if (a.notify_error) return h("span", { class: "small neg", title: a.notify_error }, icon("alert"), t(" Notifica non riuscita")); + return a.opportunity ? h("span", { class: "muted small" }, t("Non notificata")) : null; } function alertCard(a) { @@ -147,7 +148,7 @@ function alertCard(a) { return h("article", { class: `card alert-card${a.opportunity ? "" : " alert-muted"}` }, h("div", { class: "alert-top" }, a.verdict ? verdictBadge(a.verdict) : null, - a.side ? h("span", { class: "badge badge-outline" }, `Compra ${sideLabel(a.side)}`) : null, + a.side ? h("span", { class: "badge badge-outline" }, t("Compra {0}", sideLabel(a.side))) : null, h("span", { class: "muted small", title: fmt.dateTime(a.created_at) }, fmt.ago(a.created_at)), notifyState(a), ), @@ -156,12 +157,12 @@ function alertCard(a) { externalLink(a.news.url, a.news.title), h("span", { class: "muted" }, ` · ${a.news.source || ""} · ${fmt.ago(a.news.published_at)}`)) : null, h("div", { class: "alert-figures" }, h("div", { class: "move" }, h("span", { class: "muted small" }, "All'allerta"), h("b", { class: "mono" }, fmt.cents(a.price)), - h("span", { class: "muted small" }, `stima ${fmt.pct(a.blended)}`)), - h("div", { class: "move" }, h("span", { class: "muted small", title: `Vantaggio stimato sulla quota ${sideLabel(a.side)}` }, "Edge"), + h("span", { class: "muted small" }, t("stima {0}", fmt.pct(a.blended)))), + h("div", { class: "move" }, h("span", { class: "muted small", title: t("Vantaggio stimato sulla quota {0}", sideLabel(a.side)) }, t("Edge")), h("b", { class: `mono ${a.opportunity ? "pos" : ""}` }, a.edge == null ? "–" : fmt.pts(Math.abs(a.edge))), a.outlay ? h("span", { class: "muted small" }, fmt.money(a.outlay)) : null), ...moves, - h("div", { class: "move" }, h("span", { class: "muted small" }, "Adesso"), + h("div", { class: "move" }, h("span", { class: "muted small" }, t("Adesso")), h("b", { class: `mono ${moveCls(a.now_move)}` }, a.now_move == null ? "–" : fmt.pts(a.now_move)), h("span", { class: "muted small mono" }, fmt.cents(a.market.yes_price))), ), @@ -186,19 +187,19 @@ function settingsCard(ctx, s, categories) { return h("label", { class: "field field-col", for: id }, label, h("span", { class: "inline" }, input, suffix ? h("span", { class: "muted small" }, suffix) : null)); }; const hourSelect = (id, key) => { - const sel = h("select", { id, class: "input", disabled: dis, style: { width: "auto", minWidth: "96px" }, "aria-label": key === "quiet_start" ? "Inizio ore silenziose" : "Fine ore silenziose" }, + const sel = h("select", { id, class: "input", disabled: dis, style: { width: "auto", minWidth: "96px" }, "aria-label": key === "quiet_start" ? t("Inizio ore silenziose") : t("Fine ore silenziose") }, h("option", { value: "" }, "–"), Array.from({ length: 24 }, (_, i) => h("option", { value: String(i), selected: draft[key] === i }, `${String(i).padStart(2, "0")}:00`))); sel.addEventListener("change", () => { draft[key] = sel.value === "" ? null : Number(sel.value); }); return sel; }; - const match = ctx.rangeField("al-match", "Pertinenza minima", + const match = ctx.rangeField("al-match", t("Pertinenza minima"), { min: 30, max: 100, step: 5, value: Math.round(draft.min_match * 100), format: (v) => `${v}%` }, (v) => { draft.min_match = v / 100; }); if (dis) match.querySelector("input").disabled = true; - const catBoxes = h("div", { class: "chips", role: "group", "aria-label": "Categorie" }, + const catBoxes = h("div", { class: "chips", role: "group", "aria-label": t("Categorie") }, categories.map((c) => { const id = `al-cat-${c}`.replace(/\W/g, "-"); const box = h("input", { type: "checkbox", id, checked: draft.categories.includes(c), disabled: dis }); @@ -208,8 +209,8 @@ function settingsCard(ctx, s, categories) { return h("label", { class: "chip", for: id }, box, CATEGORY_LABELS[c] || c); })); - const verdictGroup = h("div", { class: "field-col", role: "radiogroup", "aria-label": "Quando notificare" }, - [["GO", "Solo quando conviene"], ["SMALL", "Anche quando conviene con una puntata piccola"]].map(([value, label]) => { + const verdictGroup = h("div", { class: "field-col", role: "radiogroup", "aria-label": t("Quando notificare") }, + [["GO", t("Solo quando conviene")], ["SMALL", t("Anche quando conviene con una puntata piccola")]].map(([value, label]) => { const input = h("input", { type: "radio", name: "al-verdict", value, checked: draft.min_verdict === value, disabled: dis }); input.addEventListener("change", () => { draft.min_verdict = value; }); return h("label", { class: "field" }, input, label); @@ -217,13 +218,13 @@ function settingsCard(ctx, s, categories) { let actions = null; if (admin) { - const save = h("button", { class: "btn btn-primary", type: "button" }, h("span", { class: "spinner", "aria-hidden": "true" }), "Salva"); + const save = h("button", { class: "btn btn-primary", type: "button" }, h("span", { class: "spinner", "aria-hidden": "true" }), t("Salva")); save.addEventListener("click", async () => { ctx.setBusy(save, true); try { const { enabled, telegram_enabled, categories: cats, min_match, max_news_age_hours, daily_budget, cooldown_hours, min_verdict, quiet_start, quiet_end } = draft; await api("/alerts/settings", { method: "PUT", body: { enabled, telegram_enabled, categories: cats, min_match, max_news_age_hours, daily_budget, cooldown_hours, min_verdict, quiet_start, quiet_end } }); - toast("Impostazioni delle allerte salvate"); + toast(t("Impostazioni delle allerte salvate")); ctx.rerender(); } catch (e) { toast(e.message, { error: true }); @@ -231,11 +232,11 @@ function settingsCard(ctx, s, categories) { } }); const testBtn = h("button", { class: "btn btn-ghost", type: "button", disabled: !s.telegram_configured, - title: s.telegram_configured ? "Invia un messaggio di prova" : "Configura prima TELEGRAM_BOT_TOKEN e TELEGRAM_CHAT_ID" }, - h("span", { class: "spinner", "aria-hidden": "true" }), "Invia messaggio di prova"); + title: s.telegram_configured ? t("Invia un messaggio di prova") : t("Configura prima TELEGRAM_BOT_TOKEN e TELEGRAM_CHAT_ID") }, + h("span", { class: "spinner", "aria-hidden": "true" }), t("Invia messaggio di prova")); testBtn.addEventListener("click", async () => { ctx.setBusy(testBtn, true); - try { await api("/alerts/test-telegram", { method: "POST" }); toast("Messaggio inviato: controlla Telegram"); } + try { await api("/alerts/test-telegram", { method: "POST" }); toast(t("Messaggio inviato: controlla Telegram")); } catch (e) { toast(e.message, { error: true }); } finally { ctx.setBusy(testBtn, false); } }); @@ -244,25 +245,25 @@ function settingsCard(ctx, s, categories) { return h("section", { class: "card", "aria-labelledby": "h-alert-settings", style: { marginTop: "16px" } }, h("div", { class: "card-head" }, - h("h2", { id: "h-alert-settings" }, "Impostazioni delle allerte"), - !admin ? h("span", { class: "muted small" }, "Solo un amministratore può modificarle") : null), + h("h2", { id: "h-alert-settings" }, t("Impostazioni delle allerte")), + !admin ? h("span", { class: "muted small" }, t("Solo un amministratore può modificarle")) : null), h("div", { class: "settings-grid" }, h("div", { class: "field-col" }, - sw("al-enabled", "Allerte attive", "enabled"), - sw("al-telegram", "Notifiche su Telegram", "telegram_enabled"), - h("p", { class: "muted small" }, s.telegram_configured ? "Telegram configurato." : "Telegram non configurato (file .env)."), + sw("al-enabled", t("Allerte attive"), "enabled"), + sw("al-telegram", t("Notifiche su Telegram"), "telegram_enabled"), + h("p", { class: "muted small" }, s.telegram_configured ? t("Telegram configurato.") : t("Telegram non configurato (file .env).")), ), - h("div", { class: "field-col" }, h("span", { class: "field-label" }, "Categorie da seguire"), catBoxes, - h("p", { class: "muted small" }, "Nessuna selezionata = tutte.")), + h("div", { class: "field-col" }, h("span", { class: "field-label" }, t("Categorie da seguire")), catBoxes, + h("p", { class: "muted small" }, t("Nessuna selezionata = tutte."))), h("div", { class: "field-col" }, match, - number("al-age", "Età massima della notizia", "max_news_age_hours", { min: 0.5, max: 72, step: 0.5, suffix: "ore" })), + number("al-age", t("Età massima della notizia"), "max_news_age_hours", { min: 0.5, max: 72, step: 0.5, suffix: "ore" })), h("div", { class: "field-col" }, - number("al-budget", "Chiamate Jev al giorno", "daily_budget", { min: 0, max: 500, step: 1 }), - number("al-cooldown", "Pausa per mercato dopo un'allerta", "cooldown_hours", { min: 0, max: 72, step: 0.5, suffix: "ore" })), - h("div", { class: "field-col" }, h("span", { class: "field-label" }, "Quando notificare"), verdictGroup), - h("div", { class: "field-col" }, h("span", { class: "field-label" }, `Ore silenziose (${s.timezone})`), + number("al-budget", t("Chiamate Jev al giorno"), "daily_budget", { min: 0, max: 500, step: 1 }), + number("al-cooldown", t("Pausa per mercato dopo un'allerta"), "cooldown_hours", { min: 0, max: 72, step: 0.5, suffix: "ore" })), + h("div", { class: "field-col" }, h("span", { class: "field-label" }, t("Quando notificare")), verdictGroup), + h("div", { class: "field-col" }, h("span", { class: "field-label" }, t("Ore silenziose ({0})", s.timezone)), h("span", { class: "inline small" }, "dalle", hourSelect("al-quiet-start", "quiet_start"), "alle", hourSelect("al-quiet-end", "quiet_end")), - h("p", { class: "muted small" }, "In queste ore le notifiche arrivano senza suono.")), + h("p", { class: "muted small" }, t("In queste ore le notifiche arrivano senza suono."))), ), actions, ); diff --git a/frontend/views/backtest.js b/frontend/views/backtest.js index ec3188d..d63d67e 100644 --- a/frontend/views/backtest.js +++ b/frontend/views/backtest.js @@ -1,9 +1,10 @@ // Backtest: Jev on resolved markets as of a past date, accuracy vs the price, and suggested parameters. +import { t } from "../i18n.js"; import { h, api, fmt, toast, icon, statTile, emptyState, infoTip, externalLink, CATEGORY_LABELS } from "../ui.js"; import { reliabilityChart } from "../charts.js"; const POLL_MS = 3000; -const STATUS = { running: ["badge-outline", "In corso"], done: ["badge-good", "Completato"], stopped: ["", "Interrotto"], failed: ["badge-critical", "Non riuscito"] }; +const STATUS = { running: ["badge-outline", t("In corso")], done: ["badge-good", t("Completato")], stopped: ["", t("Interrotto")], failed: ["badge-critical", t("Non riuscito")] }; const dayISO = (d) => d.toISOString().slice(0, 10); const b3 = (v) => (v == null ? "–" : fmt.num3(v)); const statusBadge = (st) => { const [cls, label] = STATUS[st] || ["", st]; return h("span", { class: `badge ${cls}` }, label); }; @@ -16,18 +17,18 @@ export async function viewBacktest(ctx, runId) { const running = runs.find((r) => r.running); return h("div", {}, - ctx.pageHead("Backtest", - "Come avrebbe previsto Jev i mercati già risolti, con le notizie e il prezzo di allora. Serve a capire se e quanto fidarsi di Jev prima di usare soldi veri."), + ctx.pageHead(t("Backtest"), + t("Come avrebbe previsto Jev i mercati già risolti, con le notizie e il prezzo di allora. Serve a capire se e quanto fidarsi di Jev prima di usare soldi veri.")), h("div", { class: "notice", role: "note" }, icon("alert"), h("div", {}, - h("p", {}, h("b", {}, "Attenzione al senno di poi. "), "Jev potrebbe conoscere già come sono finiti gli eventi passati. ", - "I risultati sono affidabili solo per mercati risolti dopo la data fino a cui arrivano le conoscenze del modello di Jev; per quelli più vecchi possono sembrare migliori di quanto sono."), - h("p", {}, h("b", {}, "Notizie di allora. "), "L'archivio di questa app contiene solo ciò che era già stato salvato a quella data: nessuna notizia successiva può entrare. ", - "Google News copre qualsiasi periodo, ma i suoi filtri per data lasciano passare articoli aggiornati dopo: quei casi possono sembrare migliori di quanto sono. I risultati dei soli casi con l'archivio sono mostrati a parte."), - h("p", { class: "muted small" }, "Il book storico non esiste: le scommesse simulate usano il prezzo di allora più metà dello spread tipico e le commissioni della categoria."))), + h("p", {}, h("b", {}, t("Attenzione al senno di poi. ")), t("Jev potrebbe conoscere già come sono finiti gli eventi passati. "), + t("I risultati sono affidabili solo per mercati risolti dopo la data fino a cui arrivano le conoscenze del modello di Jev; per quelli più vecchi possono sembrare migliori di quanto sono.")), + h("p", {}, h("b", {}, t("Notizie di allora. ")), t("L'archivio di questa app contiene solo ciò che era già stato salvato a quella data: nessuna notizia successiva può entrare. "), + t("Google News copre qualsiasi periodo, ma i suoi filtri per data lasciano passare articoli aggiornati dopo: quei casi possono sembrare migliori di quanto sono. I risultati dei soli casi con l'archivio sono mostrati a parte.")), + h("p", { class: "muted small" }, t("Il book storico non esiste: le scommesse simulate usano il prezzo di allora più metà dello spread tipico e le commissioni della categoria.")))), running ? progressCard(ctx, running) : admin ? newRunCard(ctx, params) : null, parametersCard(ctx, params), - runs.length ? runsCard(runs, selected?.id) : emptyState("Nessun backtest ancora", - admin ? "Scegli un periodo e avvia il primo backtest qui sopra." : "Un amministratore deve avviare il primo backtest."), + runs.length ? runsCard(runs, selected?.id) : emptyState(t("Nessun backtest ancora"), + admin ? t("Scegli un periodo e avvia il primo backtest qui sopra.") : t("Un amministratore deve avviare il primo backtest.")), selected && selected.status !== "running" ? await resultsView(ctx, selected, params) : null, ); } @@ -44,14 +45,14 @@ function newRunCard(ctx, params) { const calls = h("input", { id: "bt-calls", class: "input", type: "number", min: "1", max: "1000", value: "60", inputmode: "numeric" }); const decided = h("input", { id: "bt-decided", type: "checkbox", checked: true }); const newsSource = h("select", { id: "bt-news", class: "input" }, - h("option", { value: "auto" }, "Archivio, poi Google News"), - h("option", { value: "archive" }, "Solo archivio (nessun senno di poi)"), - h("option", { value: "google" }, "Solo Google News")); - const kinds = [["binary", "Sì / No"], ["multi", "Più esiti"]].map(([k, label]) => { + h("option", { value: "auto" }, t("Archivio, poi Google News")), + h("option", { value: "archive" }, t("Solo archivio (nessun senno di poi)")), + h("option", { value: "google" }, t("Solo Google News"))); + const kinds = [["binary", t("Sì / No")], ["multi", t("Più esiti")]].map(([k, label]) => { const box = h("input", { type: "checkbox", value: k, checked: k === "binary", id: `bt-k-${k}` }); return [box, h("label", { class: "chip", for: `bt-k-${k}` }, box, label)]; }); - const horizons = [[1, "1 giorno prima"], [7, "7 giorni prima"], [30, "30 giorni prima"]].map(([d, label]) => { + const horizons = [[1, t("1 giorno prima")], [7, t("7 giorni prima")], [30, t("30 giorni prima")]].map(([d, label]) => { const box = h("input", { type: "checkbox", value: String(d), checked: d === 7, id: `bt-h${d}` }); return [box, h("label", { class: "chip", for: `bt-h${d}` }, box, label)]; }); @@ -60,19 +61,19 @@ function newRunCard(ctx, params) { const nh = horizons.filter(([b]) => b.checked).length; const planned = Number(markets.value || 0) * nh; const cap = Math.min(planned, Number(calls.value || 0)); - estimate.textContent = `Fino a ${fmt.count(planned, "caso", "casi")} (mercati × orizzonti), al massimo ${fmt.count(cap, "chiamata", "chiamate")} a Jev. ` - + "I casi senza notizie, senza prezzo storico o già decisi dal prezzo non consumano chiamate."; + estimate.textContent = t("Fino a {0} (mercati × orizzonti), al massimo {1} a Jev. ", fmt.count(planned, t("caso"), t("casi")), fmt.count(cap, t("chiamata"), t("chiamate"))) + + t("I casi senza notizie, senza prezzo storico o già decisi dal prezzo non consumano chiamate."); }; [markets, calls, ...horizons.map(([b]) => b)].forEach((el) => el.addEventListener("input", paint)); paint(); const go = h("button", { class: "btn btn-primary", type: "button", disabled: !params.jev_enabled, - title: params.jev_enabled ? null : "Serve TYPESAFE_API_KEY" }, h("span", { class: "spinner", "aria-hidden": "true" }), icon("play"), "Avvia backtest"); + title: params.jev_enabled ? null : t("Serve TYPESAFE_API_KEY") }, h("span", { class: "spinner", "aria-hidden": "true" }), icon("play"), t("Avvia backtest")); go.addEventListener("click", async () => { const hs = horizons.filter(([b]) => b.checked).map(([b]) => Number(b.value)); - if (!hs.length) return toast("Scegli almeno un orizzonte", { error: true }); + if (!hs.length) return toast(t("Scegli almeno un orizzonte"), { error: true }); const ks = kinds.filter(([b]) => b.checked).map(([b]) => b.value); - if (!ks.length) return toast("Scegli almeno un tipo di mercato", { error: true }); + if (!ks.length) return toast(t("Scegli almeno un tipo di mercato"), { error: true }); ctx.setBusy(go, true); try { await api("/backtest/runs", { method: "POST", body: { @@ -80,7 +81,7 @@ function newRunCard(ctx, params) { min_volume: Number(volume.value), horizons: hs, max_calls: Number(calls.value), exclude_decided: decided.checked, kinds: ks, news_source: newsSource.value, } }); - toast("Backtest avviato: puoi continuare a usare la dashboard."); + toast(t("Backtest avviato: puoi continuare a usare la dashboard.")); ctx.rerender(); } catch (e) { toast(e.message, { error: true }); @@ -89,22 +90,22 @@ function newRunCard(ctx, params) { }); return h("section", { class: "card", "aria-labelledby": "h-bt-new", style: { marginBottom: "16px" } }, - h("h2", { id: "h-bt-new", style: { marginBottom: "12px" } }, "Nuovo backtest"), + h("h2", { id: "h-bt-new", style: { marginBottom: "12px" } }, t("Nuovo backtest")), h("div", { class: "settings-grid" }, - field("bt-after", "Mercati chiusi dal", after, "Meglio dopo la data fino a cui arrivano le conoscenze di Jev"), + field("bt-after", t("Mercati chiusi dal"), after, t("Meglio dopo la data fino a cui arrivano le conoscenze di Jev")), field("bt-before", "al", before), - field("bt-markets", "Mercati (i più scambiati)", markets), - field("bt-volume", "Volume minimo ($)", volume), - h("div", { class: "field-col" }, h("span", { class: "field-label" }, "Quando fare la previsione"), - h("div", { class: "chips", role: "group", "aria-label": "Orizzonti" }, horizons.map(([, chip]) => chip))), - field("bt-calls", "Limite di chiamate a Jev", calls), - field("bt-news", "Notizie di allora", newsSource, "L'archivio copre solo il periodo in cui l'app era attiva"), - h("div", { class: "field-col" }, h("span", { class: "field-label" }, "Tipo di mercati"), - h("div", { class: "chips", role: "group", "aria-label": "Tipo di mercati" }, kinds.map(([, chip]) => chip)), - h("span", { class: "muted small" }, "Più esiti: una chiamata per evento; gli esiti mostrati a Jev sono i più probabili secondo il prezzo di allora")), + field("bt-markets", t("Mercati (i più scambiati)"), markets), + field("bt-volume", t("Volume minimo ($)"), volume), + h("div", { class: "field-col" }, h("span", { class: "field-label" }, t("Quando fare la previsione")), + h("div", { class: "chips", role: "group", "aria-label": t("Orizzonti") }, horizons.map(([, chip]) => chip))), + field("bt-calls", t("Limite di chiamate a Jev"), calls), + field("bt-news", t("Notizie di allora"), newsSource, t("L'archivio copre solo il periodo in cui l'app era attiva")), + h("div", { class: "field-col" }, h("span", { class: "field-label" }, t("Tipo di mercati")), + h("div", { class: "chips", role: "group", "aria-label": t("Tipo di mercati") }, kinds.map(([, chip]) => chip)), + h("span", { class: "muted small" }, t("Più esiti: una chiamata per evento; gli esiti mostrati a Jev sono i più probabili secondo il prezzo di allora"))), ), h("label", { class: "field", for: "bt-decided", style: { marginTop: "12px" } }, decided, - "Salta i mercati già decisi dal prezzo (sotto il 3% o sopra il 97%)"), + t("Salta i mercati già decisi dal prezzo (sotto il 3% o sopra il 97%)")), estimate, h("div", { class: "actions", style: { marginTop: "4px" } }, go), ); @@ -117,23 +118,23 @@ function progressCard(ctx, run) { bar.max = Math.max(1, r.total); bar.value = r.done + r.skipped + r.failed; text.textContent = r.total - ? `${fmt.int(r.done)} valutati · ${fmt.int(r.skipped)} saltati · ${fmt.int(r.failed)} errori · su ${fmt.int(r.total)}` + ? t("{0} valutati · {1} saltati · {2} errori · su {3}", fmt.int(r.done), fmt.int(r.skipped), fmt.int(r.failed), fmt.int(r.total)) : r.message || "Preparazione…"; }; paint(run); - const stop = h("button", { class: "btn btn-ghost btn-sm", type: "button" }, icon("pause"), "Interrompi"); + const stop = h("button", { class: "btn btn-ghost btn-sm", type: "button" }, icon("pause"), t("Interrompi")); stop.addEventListener("click", async () => { stop.disabled = true; try { await api(`/backtest/runs/${run.id}/stop`, { method: "POST" }); } catch (e) { toast(e.message, { error: true }); stop.disabled = false; } }); const card = h("section", { class: "card bulk-card", "aria-live": "polite", style: { marginBottom: "16px" } }, - h("div", { class: "card-head" }, h("h2", {}, "Backtest in corso"), ctx.isAdmin() ? stop : null), bar, text); + h("div", { class: "card-head" }, h("h2", {}, t("Backtest in corso")), ctx.isAdmin() ? stop : null), bar, text); const poll = async () => { if (!card.isConnected) return; try { const r = await api(`/backtest/runs/${run.id}`); if (r.status !== "running" || !r.running) { - toast(r.status === "done" ? "Backtest completato" : `Backtest: ${r.message || STATUS[r.status]?.[1] || r.status}`); + toast(r.status === "done" ? t("Backtest completato") : t("Backtest: {0}", r.message || STATUS[r.status]?.[1] || r.status)); window.location.hash = `#/backtest/${r.id}`; return ctx.rerender(); } @@ -152,30 +153,30 @@ function parametersCard(ctx, params) { const row = (label, key, format) => h("div", { class: "param" }, h("span", { class: "muted small" }, label), h("b", { class: "mono" }, format(p[key].value)), - p[key].overridden ? h("span", { class: "badge badge-outline", title: `Valore del file .env: ${format(p[key].default)}` }, "modificato dalla dashboard") : h("span", { class: "muted small" }, "dal file .env")); + p[key].overridden ? h("span", { class: "badge badge-outline", title: t("Valore del file .env: {0}", format(p[key].default)) }, t("modificato dalla dashboard")) : h("span", { class: "muted small" }, t("dal file .env"))); let reset = null; if (ctx.isAdmin() && Object.values(p).some((v) => v.overridden)) { - reset = h("button", { class: "btn btn-ghost btn-sm", type: "button" }, "Ripristina i valori del .env"); + reset = h("button", { class: "btn btn-ghost btn-sm", type: "button" }, t("Ripristina i valori del .env")); reset.addEventListener("click", async () => { - try { await api("/backtest/parameters/reset", { method: "POST" }); toast("Valori del .env ripristinati"); ctx.rerender(); } + try { await api("/backtest/parameters/reset", { method: "POST" }); toast(t("Valori del .env ripristinati")); ctx.rerender(); } catch (e) { toast(e.message, { error: true }); } }); } return h("section", { class: "card", "aria-labelledby": "h-bt-params", style: { marginBottom: "16px" } }, - h("div", { class: "card-head" }, h("h2", { id: "h-bt-params" }, "Parametri in uso"), reset), + h("div", { class: "card-head" }, h("h2", { id: "h-bt-params" }, t("Parametri in uso")), reset), h("div", { class: "params" }, - row("Peso massimo di Jev", "MODEL_WEIGHT_MAX", fmt.pct), - row("Edge minimo per un segnale", "MIN_EDGE", (v) => fmt.pts(v).replace("+", "")), + row(t("Peso massimo di Jev"), "MODEL_WEIGHT_MAX", fmt.pct), + row(t("Edge minimo per un segnale"), "MIN_EDGE", (v) => fmt.pts(v).replace("+", "")), h("div", { class: "param" }, - h("span", { class: "muted small" }, "Calibrazione di Jev"), + h("span", { class: "muted small" }, t("Calibrazione di Jev")), h("b", { class: "mono" }, calibText(p.JEV_CALIB_A.value, p.JEV_CALIB_B.value)), p.JEV_CALIB_A.overridden || p.JEV_CALIB_B.overridden - ? h("span", { class: "badge badge-outline" }, "stimata dal backtest") : h("span", { class: "muted small" }, "nessuna correzione")))); + ? h("span", { class: "badge badge-outline" }, t("stimata dal backtest")) : h("span", { class: "muted small" }, t("nessuna correzione"))))); } function calibText(a, b) { - if (Math.abs(a) < 1e-9 && Math.abs(b - 1) < 1e-9) return "nessuna"; - return `a ${fmt.num3(a)} · b ${fmt.num3(b)}${b < 1 ? " (Jev troppo sicuro)" : b > 1 ? " (Jev troppo prudente)" : ""}`; + if (Math.abs(a) < 1e-9 && Math.abs(b - 1) < 1e-9) return t("nessuna"); + return t("a {0} · b {1}{2}", fmt.num3(a), fmt.num3(b), b < 1 ? t(" (Jev troppo sicuro)") : b > 1 ? t(" (Jev troppo prudente)") : ""); } // ---------- Runs ---------- @@ -188,7 +189,7 @@ function runsCard(runs, selectedId) { return h("tr", { class: `clickable${r.id === selectedId ? " selected" : ""}`, on: { click: (e) => { if (!e.target.closest("a")) window.location.hash = href; } } }, h("td", {}, h("a", { href, "aria-current": r.id === selectedId ? "true" : null }, fmt.dateTime(r.created_at))), h("td", { class: "nowrap small" }, `${fmt.date(p.resolved_after)} – ${fmt.date(p.resolved_before)}`), - h("td", { class: "small" }, (p.horizons || []).map((d) => `${d} gg`).join(", ")), + h("td", { class: "small" }, (p.horizons || []).map((d) => t("{0} gg", d)).join(", ")), h("td", {}, statusBadge(r.status)), h("td", { class: "num" }, fmt.int(r.done)), h("td", { class: "num" }, o ? b3(o.brier_model) : "–"), @@ -197,9 +198,9 @@ function runsCard(runs, selectedId) { ); }); return h("section", { class: "card", "aria-labelledby": "h-bt-runs", style: { marginBottom: "16px" } }, - h("h2", { id: "h-bt-runs", style: { marginBottom: "10px" } }, "Backtest eseguiti"), + h("h2", { id: "h-bt-runs", style: { marginBottom: "10px" } }, t("Backtest eseguiti")), h("div", { class: "table-wrap" }, h("table", { class: "compact-table" }, - h("thead", {}, h("tr", {}, ...["Avviato", "Mercati chiusi", "Orizzonti", "Stato", "Casi", "Brier Jev", "Brier prezzo", "Profitto"] + h("thead", {}, h("tr", {}, ...[t("Avviato"), t("Mercati chiusi"), t("Orizzonti"), t("Stato"), t("Casi"), t("Brier Jev"), t("Brier prezzo"), t("Profitto")] .map((t, i) => h("th", { scope: "col", class: i >= 4 ? "num" : null }, t)))), h("tbody", {}, rows)))); } @@ -209,29 +210,29 @@ function runsCard(runs, selectedId) { // Brier gain over the price with its 95% interval (bootstrap by market) function gainText(g) { if (!g) return "–"; - const range = g.lo == null ? "" : ` (95%: da ${fmt.num3(g.lo)} a ${fmt.num3(g.hi)})`; + const range = g.lo == null ? "" : t(" (95%: da {0} a {1})", fmt.num3(g.lo), fmt.num3(g.hi)); return `${g.mean > 0 ? "+" : ""}${fmt.num3(g.mean)}${range}`; } function gainVerdict(g, who) { if (!g || g.lo == null) return null; - if (g.lo > 0) return `${who} batte il prezzo anche nel caso peggiore dell'intervallo: il vantaggio non sembra dovuto al caso.`; - if (g.hi < 0) return `${who} fa peggio del prezzo anche nel caso migliore dell'intervallo.`; - return `L'intervallo comprende lo zero: con questi mercati non si può dire se ${who.toLowerCase()} batta davvero il prezzo.`; + if (g.lo > 0) return t("{0} batte il prezzo anche nel caso peggiore dell'intervallo: il vantaggio non sembra dovuto al caso.", who); + if (g.hi < 0) return t("{0} fa peggio del prezzo anche nel caso migliore dell'intervallo.", who); + return t("L'intervallo comprende lo zero: con questi mercati non si può dire se {0} batta davvero il prezzo.", who.toLowerCase()); } function verdictLine(o) { - if (!o.n) return "Nessun caso valutato."; + if (!o.n) return t("Nessun caso valutato."); const diff = o.brier_market - o.brier_model; - if (Math.abs(diff) < 0.005) return "Jev e il prezzo di mercato hanno previsto con la stessa accuratezza."; + if (Math.abs(diff) < 0.005) return t("Jev e il prezzo di mercato hanno previsto con la stessa accuratezza."); return diff > 0 - ? `Jev ha previsto meglio del prezzo di mercato (Brier ${b3(o.brier_model)} contro ${b3(o.brier_market)}: più basso è meglio).` - : `Il prezzo di mercato ha previsto meglio di Jev (Brier ${b3(o.brier_market)} contro ${b3(o.brier_model)}: più basso è meglio).`; + ? t("Jev ha previsto meglio del prezzo di mercato (Brier {0} contro {1}: più basso è meglio).", b3(o.brier_model), b3(o.brier_market)) + : t("Il prezzo di mercato ha previsto meglio di Jev (Brier {0} contro {1}: più basso è meglio).", b3(o.brier_market), b3(o.brier_model)); } function metricsTable(rows, firstLabel, firstValue) { return h("div", { class: "table-wrap" }, h("table", { class: "compact-table" }, - h("thead", {}, h("tr", {}, ...[firstLabel, "Casi", "Brier prezzo", "Brier Jev", "Brier blended", "Segnali giusti", "Scommesse", "Profitto", "ROI"] + h("thead", {}, h("tr", {}, ...[firstLabel, t("Casi"), t("Brier prezzo"), t("Brier Jev"), t("Brier blended"), t("Segnali giusti"), t("Scommesse"), t("Profitto"), "ROI"] .map((t, i) => h("th", { scope: "col", class: i ? "num" : null }, t)))), h("tbody", {}, rows.map((r) => { const best = Math.min(...[r.brier_market, r.brier_model, r.brier_blended].filter((v) => v != null)); @@ -239,7 +240,7 @@ function metricsTable(rows, firstLabel, firstValue) { return h("tr", {}, h("th", { scope: "row" }, firstValue(r)), h("td", { class: "num" }, fmt.int(r.n)), cell(r.brier_market), cell(r.brier_model), cell(r.brier_blended), - h("td", { class: "num" }, r.signal_hit_rate == null ? "–" : `${fmt.pct(r.signal_hit_rate)} di ${r.signals}`), + h("td", { class: "num" }, r.signal_hit_rate == null ? "–" : t("{0} di {1}", fmt.pct(r.signal_hit_rate), r.signals)), h("td", { class: "num" }, fmt.int(r.bets)), h("td", { class: `num ${r.pnl > 0 ? "pos" : r.pnl < 0 ? "neg" : ""}` }, r.bets ? fmt.signedMoney(r.pnl) : "–"), h("td", { class: "num" }, r.roi == null ? "–" : fmt.pct(r.roi))); @@ -249,8 +250,8 @@ function metricsTable(rows, firstLabel, firstValue) { async function resultsView(ctx, run, params) { const s = run.summary; if (!s || (!s.overall.n && !s.multi)) { - return h("section", { class: "card" }, h("h2", {}, "Risultati"), - h("p", { class: "secondary" }, run.message || "Nessun caso valutato con Jev in questo backtest."), + return h("section", { class: "card" }, h("h2", {}, t("Risultati")), + h("p", { class: "secondary" }, run.message || t("Nessun caso valutato con Jev in questo backtest.")), s?.skipped && Object.keys(s.skipped).length ? skippedList(s.skipped) : null); } const o = s.overall; @@ -265,32 +266,32 @@ async function resultsView(ctx, run, params) { return h("div", { class: "stack" }, h("section", { class: "card", "aria-labelledby": "h-bt-res" }, h("div", { class: "card-head" }, - h("div", {}, h("h2", { id: "h-bt-res" }, "Risultati"), - h("p", { class: "muted small" }, `Backtest del ${fmt.dateTime(run.created_at)} · ${statusBadge(run.status).textContent}${run.message ? ` · ${run.message}` : ""}`)), - infoTip("Brier score: errore medio al quadrato tra probabilità prevista ed esito (0 = perfetto, 0,25 = dire sempre 50%). Più basso è meglio.")), + h("div", {}, h("h2", { id: "h-bt-res" }, t("Risultati")), + h("p", { class: "muted small" }, t("Backtest del {0} · {1}{2}", fmt.dateTime(run.created_at), statusBadge(run.status).textContent, run.message ? ` · ${run.message}` : ""))), + infoTip(t("Brier score: errore medio al quadrato tra probabilità prevista ed esito (0 = perfetto, 0,25 = dire sempre 50%). Più basso è meglio."))), h("p", { style: { margin: "4px 0 4px" } }, verdictLine(o)), - gainVerdict(o.gain_blended, "Il blended") ? h("p", { class: "secondary small", style: { margin: "0 0 12px" } }, gainVerdict(o.gain_blended, "Il blended")) : null, - small ? h("p", { class: "notice small" }, icon("alert"), `Solo ${fmt.count(nMarkets, "mercato", "mercati")} diversi: i numeri possono cambiare molto con altri mercati. Ne servono almeno 30, meglio 100.`) : null, + gainVerdict(o.gain_blended, t("Il blended")) ? h("p", { class: "secondary small", style: { margin: "0 0 12px" } }, gainVerdict(o.gain_blended, t("Il blended"))) : null, + small ? h("p", { class: "notice small" }, icon("alert"), t("Solo {0} diversi: i numeri possono cambiare molto con altri mercati. Ne servono almeno 30, meglio 100.", fmt.count(nMarkets, t("mercato"), t("mercati")))) : null, h("div", { class: "kpis" }, - statTile("Casi valutati", fmt.int(o.n), o.markets != null ? `${fmt.count(o.markets, "mercato", "mercati")} · ${fmt.int(run.skipped)} saltati` : `${fmt.int(run.skipped)} saltati`), - statTile("Brier Jev", b3(o.brier_model), `prezzo ${b3(o.brier_market)} · blended ${b3(o.brier_blended)}`), - statTile("Vantaggio del blended sul prezzo", gainText(o.gain_blended), "Brier del prezzo − Brier del blended; > 0 è meglio"), - statTile("Segnali giusti", o.signal_hit_rate == null ? "–" : fmt.pct(o.signal_hit_rate), `${fmt.count(o.signals, "segnale", "segnali")}`), - statTile("Scommesse simulate", o.bets ? fmt.signedMoney(o.pnl) : "–", o.bets ? `${o.bets_won} vinte su ${o.bets} · ROI ${fmt.pct(o.roi)}` : "nessuna scommessa"), + statTile(t("Casi valutati"), fmt.int(o.n), o.markets != null ? t("{0} · {1} saltati", fmt.count(o.markets, t("mercato"), t("mercati")), fmt.int(run.skipped)) : t("{0} saltati", fmt.int(run.skipped))), + statTile(t("Brier Jev"), b3(o.brier_model), t("prezzo {0} · blended {1}", b3(o.brier_market), b3(o.brier_blended))), + statTile(t("Vantaggio del blended sul prezzo"), gainText(o.gain_blended), t("Brier del prezzo − Brier del blended; > 0 è meglio")), + statTile(t("Segnali giusti"), o.signal_hit_rate == null ? "–" : fmt.pct(o.signal_hit_rate), `${fmt.count(o.signals, t("segnale"), t("segnali"))}`), + statTile(t("Scommesse simulate"), o.bets ? fmt.signedMoney(o.pnl) : "–", o.bets ? t("{0} vinte su {1} · ROI {2}", o.bets_won, o.bets, fmt.pct(o.roi)) : t("nessuna scommessa")), ), s.news_sources == null ? null : h("p", { class: "muted small", style: { marginTop: "10px" } }, - `Notizie: ${fmt.int(sources.archive || 0)} casi dall'archivio, ${fmt.int(sources.google || 0)} da Google News. `, - lf ? `Solo archivio (nessun senno di poi): ${fmt.count(lf.n, "caso", "casi")}, Brier Jev ${b3(lf.brier_model)} contro prezzo ${b3(lf.brier_market)}, vantaggio del blended ${gainText(lf.gain_blended)}.` - : "Nessun caso con le sole notizie dell'archivio: i risultati possono risentire del senno di poi."), + t("Notizie: {0} casi dall'archivio, {1} da Google News. ", fmt.int(sources.archive || 0), fmt.int(sources.google || 0)), + lf ? t("Solo archivio (nessun senno di poi): {0}, Brier Jev {1} contro prezzo {2}, vantaggio del blended {3}.", fmt.count(lf.n, t("caso"), t("casi")), b3(lf.brier_model), b3(lf.brier_market), gainText(lf.gain_blended)) + : t("Nessun caso con le sole notizie dell'archivio: i risultati possono risentire del senno di poi.")), ), s.by_horizon.length > 1 || s.by_category.length > 1 ? h("section", { class: "card", "aria-labelledby": "h-bt-split" }, - h("h2", { id: "h-bt-split", style: { marginBottom: "10px" } }, "Per orizzonte e per categoria"), - metricsTable(s.by_horizon, "Previsione fatta", (r) => `${r.horizon_days} ${r.horizon_days === 1 ? "giorno" : "giorni"} prima`), + h("h2", { id: "h-bt-split", style: { marginBottom: "10px" } }, t("Per orizzonte e per categoria")), + metricsTable(s.by_horizon, t("Previsione fatta"), (r) => t("{0} {1} prima", r.horizon_days, r.horizon_days === 1 ? t("giorno") : t("giorni"))), h("div", { style: { height: "12px" } }), - metricsTable(s.by_category, "Categoria", (r) => CATEGORY_LABELS[r.category] || r.category)) : null, + metricsTable(s.by_category, t("Categoria"), (r) => CATEGORY_LABELS[r.category] || r.category)) : null, h("section", { class: "card", "aria-labelledby": "h-bt-cal" }, - h("div", { class: "card-head" }, h("h2", { id: "h-bt-cal" }, "Calibrazione"), - infoTip("Se Jev dice 70% per 10 mercati, circa 7 dovrebbero risolversi SÌ. I punti sopra la diagonale indicano previsioni troppo prudenti, quelli sotto troppo ottimiste.")), + h("div", { class: "card-head" }, h("h2", { id: "h-bt-cal" }, t("Calibrazione")), + infoTip(t("Se Jev dice 70% per 10 mercati, circa 7 dovrebbero risolversi SÌ. I punti sopra la diagonale indicano previsioni troppo prudenti, quelli sotto troppo ottimiste."))), reliabilityChart(s.calibration)), suggestionCard(ctx, s.suggestion, params), casesCard(cases, s.skipped), @@ -299,37 +300,37 @@ async function resultsView(ctx, run, params) { } function multiSignalCell(c) { - if (c.signal !== "BUY_YES" && c.signal !== "BUY_NO") return h("span", { class: "muted small" }, "Attendi"); + if (c.signal !== "BUY_YES" && c.signal !== "BUY_NO") return h("span", { class: "muted small" }, t("Attendi")); const right = (c.details?.best === c.details?.winner) === (c.signal === "BUY_YES"); - return h("span", { class: right ? "pos" : "neg" }, icon(right ? "check" : "x"), ` ${c.signal === "BUY_YES" ? "SÌ" : "NO"} su ${c.details?.best}`); + return h("span", { class: right ? "pos" : "neg" }, icon(right ? "check" : "x"), " " + t("{0} su {1}", c.signal === "BUY_YES" ? t("SÌ") : "NO", c.details?.best)); } function multiCard(run, m, cases) { const b = (v) => (v == null ? "–" : fmt.num3(v)); - const better = m.brier_model < m.brier_market - 0.005 ? "Jev ha previsto meglio del mercato" - : m.brier_model > m.brier_market + 0.005 ? "Il mercato ha previsto meglio di Jev" : "Jev e il mercato hanno previsto con la stessa accuratezza"; + const better = m.brier_model < m.brier_market - 0.005 ? t("Jev ha previsto meglio del mercato") + : m.brier_model > m.brier_market + 0.005 ? t("Il mercato ha previsto meglio di Jev") : t("Jev e il mercato hanno previsto con la stessa accuratezza"); const rows = [...cases].sort((a, c) => Math.abs(c.pnl ?? 0) - Math.abs(a.pnl ?? 0)).slice(0, 30).map((c) => h("tr", {}, h("td", { class: "q-cell" }, c.url ? externalLink(c.url, c.question) : c.question), - h("td", { class: "num nowrap" }, `${c.horizon_days} gg`, h("div", { class: "muted small" }, fmt.date(c.as_of))), + h("td", { class: "num nowrap" }, t("{0} gg", c.horizon_days), h("div", { class: "muted small" }, fmt.date(c.as_of))), h("td", {}, c.details?.winner || "–"), h("td", { class: "num" }, fmt.pct(c.price)), h("td", { class: "num" }, fmt.pct(c.model_probability)), h("td", {}, multiSignalCell(c)), h("td", { class: `num ${c.pnl > 0 ? "pos" : c.pnl < 0 ? "neg" : ""}` }, c.pnl == null ? "–" : fmt.signedMoney(c.pnl)))); return h("section", { class: "card", "aria-labelledby": "h-bt-multi" }, - h("div", { class: "card-head" }, h("h2", { id: "h-bt-multi" }, "Mercati a più esiti"), - infoTip("Brier a più esiti: somma degli errori al quadrato su tutti gli esiti (0 = perfetto, 2 = certo e sbagliato). «Favorito giusto»: quante volte l'esito più probabile ha vinto davvero.")), - h("p", { style: { margin: "4px 0 4px" } }, `${better} (Brier ${b(m.brier_model)} contro ${b(m.brier_market)}: più basso è meglio).`), + h("div", { class: "card-head" }, h("h2", { id: "h-bt-multi" }, t("Mercati a più esiti")), + infoTip(t("Brier a più esiti: somma degli errori al quadrato su tutti gli esiti (0 = perfetto, 2 = certo e sbagliato). «Favorito giusto»: quante volte l'esito più probabile ha vinto davvero."))), + h("p", { style: { margin: "4px 0 4px" } }, t("{0} (Brier {1} contro {2}: più basso è meglio).", better, b(m.brier_model), b(m.brier_market))), h("p", { class: "secondary small", style: { margin: "0 0 12px" } }, - `Vantaggio del blended sul prezzo: ${gainText(m.gain_blended)}. `, gainVerdict(m.gain_blended, "Il blended") || "", - m.winner_listed != null && m.winner_listed < 1 ? ` In ${fmt.pct(1 - m.winner_listed)} degli eventi il vincitore era tra gli «altri esiti» (poco probabile per il prezzo di allora).` : ""), - m.n < 30 ? h("p", { class: "notice small" }, icon("alert"), `Solo ${m.n} eventi: servono molti più casi per conclusioni affidabili.`) : null, + t("Vantaggio del blended sul prezzo: {0}. ", gainText(m.gain_blended)), gainVerdict(m.gain_blended, t("Il blended")) || "", + m.winner_listed != null && m.winner_listed < 1 ? t(" In {0} degli eventi il vincitore era tra gli «altri esiti» (poco probabile per il prezzo di allora).", fmt.pct(1 - m.winner_listed)) : ""), + m.n < 30 ? h("p", { class: "notice small" }, icon("alert"), t("Solo {0} eventi: servono molti più casi per conclusioni affidabili.", m.n)) : null, h("div", { class: "kpis" }, - statTile("Eventi valutati", fmt.int(m.n), `blended ${b(m.brier_blended)}`), - statTile("Probabilità data al vincitore", fmt.pct(m.winner_prob_model), `Jev · mercato ${fmt.pct(m.winner_prob_market)}`), - statTile("Favorito giusto", fmt.pct(m.favourite_right_model), `Jev · mercato ${fmt.pct(m.favourite_right_market)}`), - statTile("Scommesse simulate", m.bets ? fmt.signedMoney(m.pnl) : "–", m.bets ? `${m.bets_won} vinte su ${m.bets} · ROI ${fmt.pct(m.roi)}` : "nessuna scommessa")), + statTile(t("Eventi valutati"), fmt.int(m.n), t("blended {0}", b(m.brier_blended))), + statTile(t("Probabilità data al vincitore"), fmt.pct(m.winner_prob_model), t("Jev · mercato {0}", fmt.pct(m.winner_prob_market))), + statTile(t("Favorito giusto"), fmt.pct(m.favourite_right_model), t("Jev · mercato {0}", fmt.pct(m.favourite_right_market))), + statTile(t("Scommesse simulate"), m.bets ? fmt.signedMoney(m.pnl) : "–", m.bets ? t("{0} vinte su {1} · ROI {2}", m.bets_won, m.bets, fmt.pct(m.roi)) : t("nessuna scommessa"))), cases.length ? h("div", { class: "table-wrap", style: { marginTop: "12px" } }, h("table", { class: "compact-table cases-table" }, - h("thead", {}, h("tr", {}, ...["Evento", "Quando", "Ha vinto", "Mercato sul vincitore", "Jev sul vincitore", "Segnale", "Scommessa"] + h("thead", {}, h("tr", {}, ...[t("Evento"), t("Quando"), t("Ha vinto"), t("Mercato sul vincitore"), t("Jev sul vincitore"), t("Segnale"), t("Scommessa")] .map((t, i) => h("th", { scope: "col", class: [1, 3, 4, 6].includes(i) ? "num" : null }, t)))), h("tbody", {}, rows))) : null); } @@ -339,9 +340,9 @@ function suggestionCard(ctx, sg, params) { if (!sg.calibration) { // Summary saved by an earlier version: its suggestions were not checked out of sample return h("section", { class: "card", "aria-labelledby": "h-bt-sug" }, - h("h2", { id: "h-bt-sug" }, "Parametri suggeriti"), + h("h2", { id: "h-bt-sug" }, t("Parametri suggeriti")), h("p", { class: "secondary", style: { marginTop: "6px" } }, - "Questo backtest è stato eseguito con una versione precedente, senza la verifica sui mercati più recenti né la calibrazione di Jev. Avvia un nuovo backtest per avere suggerimenti verificati.")); + t("Questo backtest è stato eseguito con una versione precedente, senza la verifica sui mercati più recenti né la calibrazione di Jev. Avvia un nuovo backtest per avere suggerimenti verificati."))); } const w = sg.model_weight_max; const e = sg.min_edge; @@ -355,74 +356,74 @@ function suggestionCard(ctx, sg, params) { const conf = { bassa: "badge-critical", media: "", alta: "badge-good" }[sg.confidence]; let apply = null; if (ctx.isAdmin() && (useBlend || useEdge)) { - apply = h("button", { class: "btn btn-primary", type: "button" }, h("span", { class: "spinner", "aria-hidden": "true" }), "Applica i valori consigliati"); + apply = h("button", { class: "btn btn-primary", type: "button" }, h("span", { class: "spinner", "aria-hidden": "true" }), t("Applica i valori consigliati")); apply.addEventListener("click", async () => { - const body = { note: `Backtest su ${sg.markets} mercati, verificato sui più recenti` }; + const body = { note: t("Backtest su {0} mercati, verificato sui più recenti", sg.markets) }; if (useBlend) Object.assign(body, { MODEL_WEIGHT_MAX: w.suggested, JEV_CALIB_A: c.suggested.a, JEV_CALIB_B: c.suggested.b }); if (useEdge) body.MIN_EDGE = e.suggested; ctx.setBusy(apply, true); - try { await api("/backtest/parameters", { method: "PUT", body }); toast("Parametri aggiornati: valgono per le prossime previsioni"); ctx.rerender(); } + try { await api("/backtest/parameters", { method: "PUT", body }); toast(t("Parametri aggiornati: valgono per le prossime previsioni")); ctx.rerender(); } catch (err) { toast(err.message, { error: true }); ctx.setBusy(apply, false); } }); } - const status = (recommended, same) => same ? "il valore attuale è già il migliore" - : !sg.validated ? "non verificato: servono più mercati" - : recommended ? "consigliato: migliora anche sui mercati più recenti" : "non consigliato: sui mercati più recenti non migliora"; + const status = (recommended, same) => same ? t("il valore attuale è già il migliore") + : !sg.validated ? t("non verificato: servono più mercati") + : recommended ? t("consigliato: migliora anche sui mercati più recenti") : t("non consigliato: sui mercati più recenti non migliora"); const edgeRows = e.table.filter((r) => r.bets).map((r) => h("tr", { class: r.min_edge === e.suggested ? "best-row" : null }, - h("td", { class: "num" }, `${Math.round(r.min_edge * 100)} pt`), h("td", { class: "num" }, fmt.int(r.bets)), + h("td", { class: "num" }, `${Math.round(r.min_edge * 100)} ${t("pt")}`), h("td", { class: "num" }, fmt.int(r.bets)), h("td", { class: "num" }, fmt.int(r.wins)), h("td", { class: `num ${r.profit > 0 ? "pos" : r.profit < 0 ? "neg" : ""}` }, fmt.signedMoney(r.profit)), h("td", { class: "num" }, r.roi == null ? "–" : fmt.pct(r.roi)))); const check = sg.validated - ? `Verifica: parametri stimati sui ${v.train_markets} mercati chiusi prima e provati sugli altri ${v.test_markets}. ` - + `Brier del blended su questi ultimi: ${b3(v.brier_test_current)} con i valori attuali, ${b3(v.brier_test_suggested)} con quelli stimati.` - : `Non verificabile: servono almeno 10 mercati sia per stimare sia per provare (qui ${v.train_markets ?? 0} + ${v.test_markets ?? 0}). Nessun valore viene consigliato.`; + ? t("Verifica: parametri stimati sui {0} mercati chiusi prima e provati sugli altri {1}. ", v.train_markets, v.test_markets) + + t("Brier del blended su questi ultimi: {0} con i valori attuali, {1} con quelli stimati.", b3(v.brier_test_current), b3(v.brier_test_suggested)) + : t("Non verificabile: servono almeno 10 mercati sia per stimare sia per provare (qui {0} + {1}). Nessun valore viene consigliato.", v.train_markets ?? 0, v.test_markets ?? 0); return h("section", { class: "card", "aria-labelledby": "h-bt-sug" }, - h("div", { class: "card-head" }, h("h2", { id: "h-bt-sug" }, "Parametri suggeriti"), - h("span", { class: `badge ${conf}` }, `affidabilità ${sg.confidence} · ${fmt.count(sg.markets, "mercato", "mercati")}`)), + h("div", { class: "card-head" }, h("h2", { id: "h-bt-sug" }, t("Parametri suggeriti")), + h("span", { class: `badge ${conf}` }, t("affidabilità {0} · {1}", sg.confidence, fmt.count(sg.markets, t("mercato"), t("mercati"))))), h("div", { class: "params" }, - h("div", { class: "param" }, h("span", { class: "muted small" }, "Calibrazione di Jev"), + h("div", { class: "param" }, h("span", { class: "muted small" }, t("Calibrazione di Jev")), h("b", { class: "mono" }, `${calibText(c.current.a, c.current.b)} → ${calibText(c.suggested.a, c.suggested.b)}`), h("span", { class: "muted small" }, status(c.recommended, sameC))), - h("div", { class: "param" }, h("span", { class: "muted small" }, "Peso massimo di Jev"), + h("div", { class: "param" }, h("span", { class: "muted small" }, t("Peso massimo di Jev")), h("b", { class: "mono" }, `${fmt.pct(w.current)} → ${fmt.pct(w.suggested)}`), h("span", { class: "muted small" }, status(w.recommended, sameW && sameC))), - h("div", { class: "param" }, h("span", { class: "muted small" }, "Edge minimo"), - h("b", { class: "mono" }, e.suggested == null ? "–" : `${Math.round(e.current * 100)} → ${Math.round(e.suggested * 100)} pt`), - h("span", { class: "muted small" }, e.suggested == null ? "servono almeno 10 scommesse per suggerirlo" : status(e.recommended, sameE)))), + h("div", { class: "param" }, h("span", { class: "muted small" }, t("Edge minimo")), + h("b", { class: "mono" }, e.suggested == null ? "–" : t("{0} → {1} pt", Math.round(e.current * 100), Math.round(e.suggested * 100))), + h("span", { class: "muted small" }, e.suggested == null ? t("servono almeno 10 scommesse per suggerirlo") : status(e.recommended, sameE)))), h("p", { class: "small", style: { marginTop: "8px" } }, check), - edgeRows.length ? h("details", { class: "chart-table" }, h("summary", {}, "Risultato per soglia di edge (1 $ per segnale, tutti i mercati)"), + edgeRows.length ? h("details", { class: "chart-table" }, h("summary", {}, t("Risultato per soglia di edge (1 $ per segnale, tutti i mercati)")), h("div", { class: "table-wrap" }, h("table", { class: "compact-table" }, - h("thead", {}, h("tr", {}, ...["Edge minimo", "Scommesse", "Vinte", "Profitto", "ROI"].map((t) => h("th", { scope: "col", class: "num" }, t)))), + h("thead", {}, h("tr", {}, ...[t("Edge minimo"), t("Scommesse"), t("Vinte"), t("Profitto"), "ROI"].map((x) => h("th", { scope: "col", class: "num" }, x)))), h("tbody", {}, edgeRows)))) : null, h("p", { class: "muted small", style: { marginTop: "8px" } }, - "La calibrazione corregge le probabilità di Jev (troppo sicure o troppo prudenti) prima di unirle al prezzo. ", - "Un valore è consigliato solo se, stimato sui mercati più vecchi, migliora anche i più recenti; i valori mostrati sono poi ristimati su tutti. ", - "Valgono per le previsioni future; puoi tornare ai valori del .env quando vuoi."), + t("La calibrazione corregge le probabilità di Jev (troppo sicure o troppo prudenti) prima di unirle al prezzo. "), + t("Un valore è consigliato solo se, stimato sui mercati più vecchi, migliora anche i più recenti; i valori mostrati sono poi ristimati su tutti. "), + t("Valgono per le previsioni future; puoi tornare ai valori del .env quando vuoi.")), apply ? h("div", { class: "actions", style: { marginTop: "10px" } }, apply) : null, ); } function skippedList(skipped) { - return h("ul", { class: "bullets small" }, Object.entries(skipped).map(([k, n]) => h("li", {}, `${k}: ${fmt.int(n)}`))); + return h("ul", { class: "bullets small" }, Object.entries(skipped).map(([k, n]) => h("li", {}, `${t(k)}: ${fmt.int(n)}`))); } const CASES_PAGE = 15; function casesCard(cases, skipped) { const rowOf = (c) => { - const outcome = c.resolved_yes ? "SÌ" : "NO"; + const outcome = c.resolved_yes ? t("SÌ") : "NO"; const right = c.signal === "HOLD" ? null : (c.signal === "BUY_YES") === c.resolved_yes; return h("tr", {}, h("td", { class: "q-cell" }, c.url ? externalLink(c.url, c.question) : c.question, - c.news?.length ? h("details", { class: "small" }, h("summary", {}, fmt.count(c.news.length, "notizia", "notizie")), + c.news?.length ? h("details", { class: "small" }, h("summary", {}, fmt.count(c.news.length, t("notizia"), t("notizie"))), h("ul", { class: "bullets" }, c.news.map((n) => h("li", {}, `${n.title} · ${n.source} · ${fmt.date(n.published_at)}`)))) : null), - h("td", { class: "num nowrap" }, `${c.horizon_days} gg`, h("div", { class: "muted small" }, fmt.date(c.as_of))), + h("td", { class: "num nowrap" }, t("{0} gg", c.horizon_days), h("div", { class: "muted small" }, fmt.date(c.as_of))), h("td", { class: "num" }, fmt.cents(c.price)), h("td", { class: "num" }, fmt.pct(c.model_probability)), h("td", { class: "num" }, fmt.pct(c.blended_probability)), h("td", { class: "num" }, h("b", {}, outcome)), - h("td", { class: "nowrap" }, c.signal === "HOLD" ? h("span", { class: "muted small" }, "Attendi") - : h("span", { class: right ? "pos" : "neg" }, icon(right ? "check" : "x"), c.signal === "BUY_YES" ? " Compra SÌ" : " Compra NO")), + h("td", { class: "nowrap" }, c.signal === "HOLD" ? h("span", { class: "muted small" }, t("Attendi")) + : h("span", { class: right ? "pos" : "neg" }, icon(right ? "check" : "x"), c.signal === "BUY_YES" ? t(" Compra SÌ") : t(" Compra NO"))), h("td", { class: `num ${c.pnl > 0 ? "pos" : c.pnl < 0 ? "neg" : ""}` }, c.pnl == null ? "–" : fmt.signedMoney(c.pnl)), ); }; @@ -431,17 +432,17 @@ function casesCard(cases, skipped) { const tbody = h("tbody", {}, sorted.slice(0, CASES_PAGE).map(rowOf)); let more = null; if (sorted.length > CASES_PAGE) { - more = h("button", { class: "btn btn-ghost btn-sm", type: "button" }, `Mostra tutti i ${sorted.length} casi`); + more = h("button", { class: "btn btn-ghost btn-sm", type: "button" }, t("Mostra tutti i {0} casi", sorted.length)); more.addEventListener("click", () => { tbody.replaceChildren(...sorted.map(rowOf)); more.remove(); }); } return h("section", { class: "card", "aria-labelledby": "h-bt-cases" }, - h("div", { class: "card-head" }, h("h2", { id: "h-bt-cases" }, `Casi valutati (${cases.length})`), - h("span", { class: "muted small" }, "prima le vincite e le perdite più grandi")), + h("div", { class: "card-head" }, h("h2", { id: "h-bt-cases" }, t("Casi valutati ({0})", cases.length)), + h("span", { class: "muted small" }, t("prima le vincite e le perdite più grandi"))), h("div", { class: "table-wrap" }, h("table", { class: "compact-table cases-table" }, - h("thead", {}, h("tr", {}, ...["Mercato", "Quando", "Prezzo", "Jev", "Blended", "Esito", "Segnale", "Scommessa"] + h("thead", {}, h("tr", {}, ...[t("Mercato"), t("Quando"), t("Prezzo"), t("Jev"), t("Blended"), t("Esito"), t("Segnale"), t("Scommessa")] .map((t, i) => h("th", { scope: "col", class: i && i !== 6 ? "num" : null }, t)))), tbody)), more ? h("div", { style: { marginTop: "10px" } }, more) : null, - skipped && Object.keys(skipped).length ? h("div", { style: { marginTop: "10px" } }, h("p", { class: "muted small" }, "Casi saltati:"), skippedList(skipped)) : null, + skipped && Object.keys(skipped).length ? h("div", { style: { marginTop: "10px" } }, h("p", { class: "muted small" }, t("Casi saltati:")), skippedList(skipped)) : null, ); } diff --git a/frontend/views/bulk.js b/frontend/views/bulk.js index 29d04cf..d2998d7 100644 --- a/frontend/views/bulk.js +++ b/frontend/views/bulk.js @@ -1,4 +1,5 @@ // "Valuta tutti con Jev": starts a Jev forecast on every market with recent news and shows its progress. +import { t } from "../i18n.js"; import { h, api, fmt, toast, icon } from "../ui.js"; const POLL_MS = 3000; @@ -7,7 +8,7 @@ const POLL_MS = 3000; export function bulkPredict(ctx) { const panel = h("div", { class: "bulk-panel", hidden: true, "aria-live": "polite" }); const button = h("button", { class: "btn btn-ghost", type: "button", disabled: true }, - h("span", { class: "spinner", "aria-hidden": "true" }), icon("play"), "Valuta tutti con Jev"); + h("span", { class: "spinner", "aria-hidden": "true" }), icon("play"), t("Valuta tutti con Jev")); if (!ctx.isAdmin()) return { button: null, panel: null }; let state = null; @@ -30,12 +31,12 @@ export function bulkPredict(ctx) { function paint() { const running = state.running; - const blocker = !state.jev_enabled ? "Serve TYPESAFE_API_KEY nel file .env." - : state.eligible.all === 0 ? "Nessun mercato aperto ha notizie recenti collegate. Aggiorna notizie e mercati." + const blocker = !state.jev_enabled ? t("Serve TYPESAFE_API_KEY nel file .env.") + : state.eligible.all === 0 ? t("Nessun mercato aperto ha notizie recenti collegate. Aggiorna notizie e mercati.") : null; button.disabled = running || Boolean(blocker); button.classList.toggle("loading", running); - button.title = running ? "Valutazione in corso" : blocker || `Chiede una previsione a Jev per ${fmt.count(state.eligible.all, "mercato", "mercati")} con notizie recenti`; + button.title = running ? t("Valutazione in corso") : blocker || t("Chiede una previsione a Jev per {0} con notizie recenti", fmt.count(state.eligible.all, t("mercato"), t("mercati"))); if (running) { wasRunning = true; paintProgress(); @@ -50,7 +51,7 @@ export function bulkPredict(ctx) { function minutes(n) { const m = Math.ceil(n / Math.max(1, state.jev_rpm)); - return m <= 1 ? "circa un minuto" : `circa ${fmt.int(m)} minuti`; + return m <= 1 ? t("circa un minuto") : t("circa {0} minuti", fmt.int(m)); } function askConfirm() { @@ -59,9 +60,9 @@ export function bulkPredict(ctx) { const cost = h("p", { class: "small" }); const paintCost = () => { const n = onlyNew ? fresh : all; - cost.textContent = `${fmt.count(n, "chiamata", "chiamate")} a pagamento a TypeSafe, una per mercato. ` - + `Con il limite di ${fmt.int(state.jev_rpm)} richieste al minuto servono ${minutes(n)}. ` - + "Prima vengono aggiornati prezzi e collegamenti; le scommesse simulate seguono le regole del portafoglio."; + cost.textContent = t("{0} a pagamento a TypeSafe, una per mercato. ", fmt.count(n, t("chiamata"), t("chiamate"))) + + t("Con il limite di {0} richieste al minuto servono {1}. ", fmt.int(state.jev_rpm), minutes(n)) + + t("Prima vengono aggiornati prezzi e collegamenti; le scommesse simulate seguono le regole del portafoglio."); go.disabled = n === 0; }; const radio = (value, label, checked) => { @@ -69,12 +70,12 @@ export function bulkPredict(ctx) { input.addEventListener("change", () => { onlyNew = value === "new"; paintCost(); }); return h("label", { class: "field" }, input, label); }; - const go = h("button", { class: "btn btn-primary btn-sm", type: "button" }, h("span", { class: "spinner", "aria-hidden": "true" }), "Avvia valutazione"); + const go = h("button", { class: "btn btn-primary btn-sm", type: "button" }, h("span", { class: "spinner", "aria-hidden": "true" }), t("Avvia valutazione")); go.addEventListener("click", async () => { ctx.setBusy(go, true); try { state = await api("/markets/predict-all", { method: "POST", params: { only_new: onlyNew } }); - toast("Valutazione avviata: puoi continuare a usare la dashboard."); + toast(t("Valutazione avviata: puoi continuare a usare la dashboard.")); delete panel.dataset.open; wasRunning = true; paintProgress(); @@ -87,14 +88,14 @@ export function bulkPredict(ctx) { panel.dataset.open = "1"; panel.hidden = false; panel.replaceChildren(h("div", { class: "card bulk-card", role: "group", "aria-labelledby": "h-bulk" }, - h("h2", { id: "h-bulk" }, "Valutare tutti i mercati con Jev?"), - h("div", { class: "bulk-scope", role: "radiogroup", "aria-label": "Quali mercati" }, - radio("all", `Tutti quelli con notizie recenti (${fmt.int(all)})`, true), - radio("new", `Solo mai valutati o con notizie nuove (${fmt.int(fresh)})`, false), + h("h2", { id: "h-bulk" }, t("Valutare tutti i mercati con Jev?")), + h("div", { class: "bulk-scope", role: "radiogroup", "aria-label": t("Quali mercati") }, + radio("all", t("Tutti quelli con notizie recenti ({0})", fmt.int(all)), true), + radio("new", t("Solo mai valutati o con notizie nuove ({0})", fmt.int(fresh)), false), ), cost, h("div", { class: "confirm-actions" }, go, - h("button", { class: "btn btn-ghost btn-sm", type: "button", on: { click: close } }, "Annulla")), + h("button", { class: "btn btn-ghost btn-sm", type: "button", on: { click: close } }, t("Annulla"))), )); paintCost(); go.focus(); @@ -108,15 +109,15 @@ export function bulkPredict(ctx) { } function counters() { - const parts = [`${fmt.int(state.done)} di ${fmt.int(state.total)} valutati`]; - if (state.skipped) parts.push(`${fmt.int(state.skipped)} saltati`); - if (state.failed) parts.push(`${fmt.int(state.failed)} con errore`); + const parts = [t("{0} di {1} valutati", fmt.int(state.done), fmt.int(state.total))]; + if (state.skipped) parts.push(t("{0} saltati", fmt.int(state.skipped))); + if (state.failed) parts.push(t("{0} con errore", fmt.int(state.failed))); return parts.join(" · "); } function paintProgress() { const processed = state.done + state.skipped + state.failed; - const stopBtn = h("button", { class: "btn btn-ghost btn-sm", type: "button" }, icon("pause"), "Interrompi"); + const stopBtn = h("button", { class: "btn btn-ghost btn-sm", type: "button" }, icon("pause"), t("Interrompi")); stopBtn.addEventListener("click", async () => { stopBtn.disabled = true; try { state = await api("/markets/predict-all/stop", { method: "POST" }); paint(); } @@ -124,10 +125,10 @@ export function bulkPredict(ctx) { }); panel.hidden = false; panel.replaceChildren(h("div", { class: "card bulk-card" }, - h("div", { class: "card-head" }, h("h2", {}, "Valutazione Jev in corso"), stopBtn), - h("progress", { max: String(Math.max(1, state.total)), value: String(processed), "aria-label": "Avanzamento" }), + h("div", { class: "card-head" }, h("h2", {}, t("Valutazione Jev in corso")), stopBtn), + h("progress", { max: String(Math.max(1, state.total)), value: String(processed), "aria-label": t("Avanzamento") }), state.total ? h("p", { class: "small mono" }, counters()) : null, - state.current ? h("p", { class: "small muted bulk-current" }, "Ora: ", state.current) : null, + state.current ? h("p", { class: "small muted bulk-current" }, t("Ora: "), state.current) : null, state.message ? h("p", { class: "small" }, state.message) : null, )); } @@ -136,9 +137,9 @@ export function bulkPredict(ctx) { panel.hidden = false; panel.replaceChildren(h("div", { class: "card bulk-card" }, h("div", { class: "card-head" }, - h("h2", {}, "Valutazione di tutti i mercati"), - h("button", { class: "btn btn-ghost btn-sm btn-square", type: "button", "aria-label": "Chiudi", on: { click: dismiss } }, icon("x"))), - h("p", { class: "small" }, state.message || "Completata"), + h("h2", {}, t("Valutazione di tutti i mercati")), + h("button", { class: "btn btn-ghost btn-sm btn-square", type: "button", "aria-label": t("Chiudi"), on: { click: dismiss } }, icon("x"))), + h("p", { class: "small" }, state.message || t("Completata")), h("p", { class: "small mono" }, `${counters()} · ${fmt.ago(state.finished_at)}`), ...state.errors.map((e) => h("p", { class: "small muted" }, e)), )); diff --git a/frontend/views/method.js b/frontend/views/method.js index 05880c7..cd4414d 100644 --- a/frontend/views/method.js +++ b/frontend/views/method.js @@ -1,4 +1,5 @@ // "Come funziona": how news becomes a Polymarket signal, with the live configuration values. +import { t } from "../i18n.js"; import { h, fmt, GLOSSARY } from "../ui.js"; import { calibrated, explainSentence, pool } from "../explain.js"; @@ -28,144 +29,144 @@ export async function viewMethod(ctx) { const kv = (label, value) => h("span", { class: "kv" }, label, " ", h("b", { class: "mono" }, value)); return h("div", { class: "method" }, - ctx.pageHead("Come funziona la valutazione", - "Dalle notizie a un segnale su Polymarket, passo per passo. I valori evidenziati sono quelli configurati su questo server."), + ctx.pageHead(t("Come funziona la valutazione"), + t("Dalle notizie a un segnale su Polymarket, passo per passo. I valori evidenziati sono quelli configurati su questo server.")), h("section", { class: "card", style: { marginBottom: "16px" } }, - h("h2", {}, "Cosa dice il prezzo di un mercato"), - p("Su Polymarket ogni mercato è una domanda con risposta SÌ o NO, per esempio «La Fed taglierà i tassi a dicembre?». ", - "Una quota SÌ paga 1 $ se l'evento accade e 0 se non accade. Se costa ", h("b", { class: "mono" }, "35¢"), - ", chi compra e chi vende stanno dicendo che l'evento ha circa il ", h("b", {}, "35%"), " di probabilità."), - p("Scommettere ha senso solo se hai motivo di pensare che la probabilità vera sia diversa dal prezzo, e di abbastanza da coprire le commissioni. ", - "Questa app cerca questi casi nelle notizie recenti."), + h("h2", {}, t("Cosa dice il prezzo di un mercato")), + p(t("Su Polymarket ogni mercato è una domanda con risposta SÌ o NO, per esempio «La Fed taglierà i tassi a dicembre?». "), + t("Una quota SÌ paga 1 $ se l'evento accade e 0 se non accade. Se costa "), h("b", { class: "mono" }, "35¢"), + t(", chi compra e chi vende stanno dicendo che l'evento ha circa il "), h("b", {}, "35%"), t(" di probabilità.")), + p(t("Scommettere ha senso solo se hai motivo di pensare che la probabilità vera sia diversa dal prezzo, e di abbastanza da coprire le commissioni. "), + t("Questa app cerca questi casi nelle notizie recenti.")), ), h("ol", { class: "stages" }, - stage(1, "Raccolta e classificazione delle notizie", - p(`Le fonti attive (${fmt.int(st.sources_active ?? 0)}) vengono lette a intervalli regolari. Ogni notizia viene riassunta e classificata da Jev: `, - "categoria, regione, autorevolezza, clickbait, se è un'opinione e quanto è ", h("b", {}, "rilevante per i mercati"), "."), - p("Le notizie quasi identiche di testate diverse vengono raggruppate, così lo stesso fatto non conta due volte."), + stage(1, t("Raccolta e classificazione delle notizie"), + p(t("Le fonti attive ({0}) vengono lette a intervalli regolari. Ogni notizia viene riassunta e classificata da Jev: ", fmt.int(st.sources_active ?? 0)), + t("categoria, regione, autorevolezza, clickbait, se è un'opinione e quanto è "), h("b", {}, t("rilevante per i mercati")), "."), + p(t("Le notizie quasi identiche di testate diverse vengono raggruppate, così lo stesso fatto non conta due volte.")), ), - stage(2, "Collegamento notizie ↔ mercati", - p("Per ogni mercato aperto l'app cerca le notizie recenti che parlano dello stesso argomento, in due modi combinati:"), + stage(2, t("Collegamento notizie ↔ mercati"), + p(t("Per ogni mercato aperto l'app cerca le notizie recenti che parlano dello stesso argomento, in due modi combinati:")), h("ul", { class: "bullets" }, - h("li", {}, h("b", {}, "Significato:"), " somiglianza semantica tra la domanda e il titolo, o il titolo con l'inizio del testo (embedding)."), - h("li", {}, h("b", {}, "Termini chiave:"), " nomi, sigle, numeri e parole distintive della domanda, con i sinonimi più comuni (Fed = Federal Reserve = Powell, BTC = Bitcoin, 100k = 100.000)."), + h("li", {}, h("b", {}, t("Significato:")), t(" somiglianza semantica tra la domanda e il titolo, o il titolo con l'inizio del testo (embedding).")), + h("li", {}, h("b", {}, t("Termini chiave:")), t(" nomi, sigle, numeri e parole distintive della domanda, con i sinonimi più comuni (Fed = Federal Reserve = Powell, BTC = Bitcoin, 100k = 100.000).")), ), - p("Una notizia sullo stesso tema ma su un soggetto diverso (la BCE per un mercato sulla Fed) non cita nessun nome della domanda e viene scartata."), + p(t("Una notizia sullo stesso tema ma su un soggetto diverso (la BCE per un mercato sulla Fed) non cita nessun nome della domanda e viene scartata.")), st.targeted_news_enabled - ? p("Per i mercati più scambiati l'app cerca anche su Google News le notizie che contengono i termini chiave: così arrivano notizie anche su argomenti che le fonti abituali non coprono.") + ? p(t("Per i mercati più scambiati l'app cerca anche su Google News le notizie che contengono i termini chiave: così arrivano notizie anche su argomenti che le fonti abituali non coprono.")) : null, - h("p", { class: "kvs" }, kv("Pertinenza minima", fmt.pct(threshold)), kv("Finestra", `${hours} ore`), kv("Notizie per previsione", String(maxArticles))), + h("p", { class: "kvs" }, kv(t("Pertinenza minima"), fmt.pct(threshold)), kv(t("Finestra"), t("{0} ore", hours)), kv(t("Notizie per previsione"), String(maxArticles))), ), - stage(3, "Quali notizie legge Jev", - p(`Delle notizie collegate Jev legge le ${maxArticles} più utili. L'utilità tiene conto di:`), + stage(3, t("Quali notizie legge Jev"), + p(t("Delle notizie collegate Jev legge le {0} più utili. L'utilità tiene conto di:", maxArticles)), h("ul", { class: "bullets" }, - h("li", {}, h("b", {}, "Pertinenza"), " con la domanda."), - h("li", {}, h("b", {}, "Affidabilità della fonte:"), " autorevolezza, clickbait e articoli d'opinione."), - h("li", {}, h("b", {}, "Freschezza:"), " il peso si dimezza ogni due giorni."), - h("li", {}, h("b", {}, "Giudizio di Jev:"), " le notizie che Jev ha già giudicato non rilevanti per quel mercato vengono tolte."), + h("li", {}, h("b", {}, t("Pertinenza")), t(" con la domanda.")), + h("li", {}, h("b", {}, t("Affidabilità della fonte:")), t(" autorevolezza, clickbait e articoli d'opinione.")), + h("li", {}, h("b", {}, t("Freschezza:")), t(" il peso si dimezza ogni due giorni.")), + h("li", {}, h("b", {}, t("Giudizio di Jev:")), t(" le notizie che Jev ha già giudicato non rilevanti per quel mercato vengono tolte.")), ), - p("Per ogni storia passa un solo articolo, con il numero di fonti che l'hanno riportata: una notizia confermata da più testate è più credibile. ", - "Al massimo 3 articoli per fonte, così le evidenze sono varie."), + p(t("Per ogni storia passa un solo articolo, con il numero di fonti che l'hanno riportata: una notizia confermata da più testate è più credibile. "), + t("Al massimo 3 articoli per fonte, così le evidenze sono varie.")), ), - stage(4, "Le domande a Jev", - p("Per ogni mercato Jev riceve le regole di risoluzione, la scadenza, la data di oggi e le notizie collegate, e risponde in una sola chiamata a:"), + stage(4, t("Le domande a Jev"), + p(t("Per ogni mercato Jev riceve le regole di risoluzione, la scadenza, la data di oggi e le notizie collegate, e risponde in una sola chiamata a:")), h("ul", { class: "bullets" }, - h("li", {}, h("b", {}, "Il mercato si risolverà SÌ?"), " → una probabilità (la stima di Jev)."), - h("li", {}, h("b", {}, "Quanto le notizie informano l'esito?"), " → la forza delle evidenze, da 0% a 100%."), - h("li", {}, h("b", {}, "Per ogni notizia:"), " è rilevante? favorisce il SÌ, il NO o è neutra?"), + h("li", {}, h("b", {}, t("Il mercato si risolverà SÌ?")), t(" → una probabilità (la stima di Jev).")), + h("li", {}, h("b", {}, t("Quanto le notizie informano l'esito?")), t(" → la forza delle evidenze, da 0% a 100%.")), + h("li", {}, h("b", {}, t("Per ogni notizia:")), t(" è rilevante? favorisce il SÌ, il NO o è neutra?")), ), - p(h("b", {}, "Jev non vede il prezzo di mercato."), " Se lo vedesse tenderebbe a copiarlo, e la sua stima non direbbe nulla di nuovo."), + p(h("b", {}, t("Jev non vede il prezzo di mercato.")), t(" Se lo vedesse tenderebbe a copiarlo, e la sua stima non direbbe nulla di nuovo.")), ), - stage(5, "Probabilità finale (blended)", - p("Un mercato liquido aggrega le opinioni di molte persone e di solito è ben calibrato. Per questo la stima di Jev non sostituisce il prezzo: ", - "si fa una media pesata, e il peso di Jev cresce solo quando le notizie sono forti."), - p(h("b", {}, "Calibrazione."), " Prima, la stima di Jev viene corretta con quanto si è visto sui mercati già risolti: se Jev è stato troppo sicuro di sé la sua stima viene ammorbidita, se è stato troppo prudente viene resa più netta (scala di Platt, stimata dal backtest). ", - (st.jev_calib_a ?? 0) === 0 && (st.jev_calib_b ?? 1) === 1 ? "Per ora nessuna correzione è attiva." : `Correzione attiva: a ${fmt.num3(st.jev_calib_a)}, b ${fmt.num3(st.jev_calib_b)}.`), - h("p", { class: "formula" }, "logit(Jev corretto) = a + b × logit(stima Jev)"), - h("p", { class: "formula" }, `w = ${fmt.pct(maxW)} × forza delle evidenze`), + stage(5, t("Probabilità finale (blended)"), + p(t("Un mercato liquido aggrega le opinioni di molte persone e di solito è ben calibrato. Per questo la stima di Jev non sostituisce il prezzo: "), + t("si fa una media pesata, e il peso di Jev cresce solo quando le notizie sono forti.")), + p(h("b", {}, t("Calibrazione.")), t(" Prima, la stima di Jev viene corretta con quanto si è visto sui mercati già risolti: se Jev è stato troppo sicuro di sé la sua stima viene ammorbidita, se è stato troppo prudente viene resa più netta (scala di Platt, stimata dal backtest). "), + (st.jev_calib_a ?? 0) === 0 && (st.jev_calib_b ?? 1) === 1 ? t("Per ora nessuna correzione è attiva.") : t("Correzione attiva: a {0}, b {1}.", fmt.num3(st.jev_calib_a), fmt.num3(st.jev_calib_b))), + h("p", { class: "formula" }, t("logit(Jev corretto) = a + b × logit(stima Jev)")), + h("p", { class: "formula" }, t("w = {0} × forza delle evidenze", fmt.pct(maxW))), st.blend_method === "linear" - ? h("p", { class: "formula" }, "blended = w × Jev corretto + (1 − w) × prezzo") - : h("p", { class: "formula" }, "logit(blended) = w × logit(Jev corretto) + (1 − w) × logit(prezzo)"), - st.blend_method === "linear" ? null : p("La media si fa sulle quote logaritmiche (logit p = ln(p / (1 − p))): è il modo standard di unire previsioni ben calibrate. Una media semplice le rende troppo timide."), - (st.jev_samples ?? 1) > 1 ? p(`Ogni previsione chiede a Jev ${st.jev_samples} volte e fa la media delle risposte: meno rumore, ma ogni chiamata si paga.`) : null, - p("Con evidenze deboli la probabilità finale resta vicina al prezzo; con evidenze decisive Jev può pesare al massimo il ", h("b", {}, fmt.pct(maxW)), "."), + ? h("p", { class: "formula" }, t("blended = w × Jev corretto + (1 − w) × prezzo")) + : h("p", { class: "formula" }, t("logit(blended) = w × logit(Jev corretto) + (1 − w) × logit(prezzo)")), + st.blend_method === "linear" ? null : p(t("La media si fa sulle quote logaritmiche (logit p = ln(p / (1 − p))): è il modo standard di unire previsioni ben calibrate. Una media semplice le rende troppo timide.")), + (st.jev_samples ?? 1) > 1 ? p(t("Ogni previsione chiede a Jev {0} volte e fa la media delle risposte: meno rumore, ma ogni chiamata si paga.", st.jev_samples)) : null, + p(t("Con evidenze deboli la probabilità finale resta vicina al prezzo; con evidenze decisive Jev può pesare al massimo il "), h("b", {}, fmt.pct(maxW)), "."), ), - stage(6, "Edge e soglie", - h("p", { class: "formula" }, "edge = blended − prezzo"), - p("Un segnale compare solo se ", h("b", {}, `|edge| ≥ ${Math.round(minEdge * 100)} punti`), " e ", - h("b", {}, `forza delle evidenze ≥ ${fmt.pct(minEv)}`), ". Edge positivo: ", h("b", {}, "Compra SÌ"), ". Edge negativo: ", h("b", {}, "Compra NO"), - ". Altrimenti: ", h("b", {}, "Attendi"), "."), + stage(6, t("Edge e soglie"), + h("p", { class: "formula" }, t("edge = blended − prezzo")), + p(t("Un segnale compare solo se "), h("b", {}, t("|edge| ≥ {0} punti", Math.round(minEdge * 100))), " e ", + h("b", {}, t("forza delle evidenze ≥ {0}", fmt.pct(minEv))), t(". Edge positivo: "), h("b", {}, t("Compra SÌ")), t(". Edge negativo: "), h("b", {}, t("Compra NO")), + t(". Altrimenti: "), h("b", {}, t("Attendi")), "."), ), - stage(7, "Quanto puntare", - p("La puntata segue il criterio di Kelly, che massimizza la crescita del capitale nel lungo periodo se le probabilità sono giuste. ", - "Poiché le probabilità sono stime, si usa solo una frazione: ", h("b", {}, `${String(kelly).replace(".", ",")} × Kelly`), "."), - h("p", { class: "formula" }, "Kelly (SÌ) = (blended − prezzo) / (1 − prezzo)"), + stage(7, t("Quanto puntare"), + p(t("La puntata segue il criterio di Kelly, che massimizza la crescita del capitale nel lungo periodo se le probabilità sono giuste. "), + t("Poiché le probabilità sono stime, si usa solo una frazione: "), h("b", {}, t("{0} × Kelly", fmt.dec(kelly))), "."), + h("p", { class: "formula" }, t("Kelly (SÌ) = (blended − prezzo) / (1 − prezzo)")), ), - stage(8, "Conviene davvero? La valutazione economica", - p("Un edge sulla carta non basta. Prima di suggerire una puntata l'app controlla:"), + stage(8, t("Conviene davvero? La valutazione economica"), + p(t("Un edge sulla carta non basta. Prima di suggerire una puntata l'app controlla:")), h("ul", { class: "bullets" }, - h("li", {}, h("b", {}, "Prezzo reale:"), " legge il book di Polymarket e calcola il prezzo medio che pagheresti per quella cifra, commissioni incluse. Lo spread spesso si mangia l'edge."), - h("li", {}, h("b", {}, "Incertezza:"), " la probabilità viene abbassata di z volte il suo errore tipico (più notizie forti, meno errore). Le decisioni usano questa probabilità prudente."), - h("li", {}, h("b", {}, "Tempo:"), " il rendimento atteso viene annualizzato e confrontato con una soglia (tasso senza rischio + premio). Pochi punti su un mercato che si chiude tra un anno non valgono i soldi bloccati."), - h("li", {}, h("b", {}, "Quanto puntare:"), " Kelly calcolato sul book (puntare di più peggiora il prezzo), una frazione di Kelly, poi i limiti per mercato, evento, categoria, totale investito e profondità del book."), + h("li", {}, h("b", {}, t("Prezzo reale:")), t(" legge il book di Polymarket e calcola il prezzo medio che pagheresti per quella cifra, commissioni incluse. Lo spread spesso si mangia l'edge.")), + h("li", {}, h("b", {}, t("Incertezza:")), t(" la probabilità viene abbassata di z volte il suo errore tipico (più notizie forti, meno errore). Le decisioni usano questa probabilità prudente.")), + h("li", {}, h("b", {}, t("Tempo:")), t(" il rendimento atteso viene annualizzato e confrontato con una soglia (tasso senza rischio + premio). Pochi punti su un mercato che si chiude tra un anno non valgono i soldi bloccati.")), + h("li", {}, h("b", {}, t("Quanto puntare:")), t(" Kelly calcolato sul book (puntare di più peggiora il prezzo), una frazione di Kelly, poi i limiti per mercato, evento, categoria, totale investito e profondità del book.")), ), - p("I tre preset (prudente, bilanciato, aggressivo) cambiano queste soglie. Il verdetto è ", - h("b", {}, "Conviene"), ", ", h("b", {}, "Conviene poco"), " (puntata ridotta dai limiti) o ", h("b", {}, "Non conviene"), ", sempre con i motivi."), - p("Ogni scommessa che conviene entra automaticamente nel ", h("a", { href: "#/portafoglio" }, "portafoglio simulato"), - ", al prezzo reale del momento, e si chiude quando il mercato si risolve. Puoi escludere singole scommesse, mercati, eventi o categorie."), + p(t("I tre preset (prudente, bilanciato, aggressivo) cambiano queste soglie. Il verdetto è "), + h("b", {}, t("Conviene")), ", ", h("b", {}, t("Conviene poco")), t(" (puntata ridotta dai limiti) o "), h("b", {}, t("Non conviene")), t(", sempre con i motivi.")), + p(t("Ogni scommessa che conviene entra automaticamente nel "), h("a", { href: "#/portafoglio" }, t("portafoglio simulato")), + t(", al prezzo reale del momento, e si chiude quando il mercato si risolve. Puoi escludere singole scommesse, mercati, eventi o categorie.")), ), - stage(9, "Cosa fare: comprare, aspettare, vendere", - p("La scheda «Cosa fare» del mercato trasforma tutto questo in istruzioni:"), + stage(9, t("Cosa fare: comprare, aspettare, vendere"), + p(t("La scheda «Cosa fare» del mercato trasforma tutto questo in istruzioni:")), h("ul", { class: "bullets" }, - h("li", {}, h("b", {}, "Compra SÌ / NO"), " con un ordine limite: il prezzo massimo, le quote e la cifra. Appena comprate, un ordine limite di vendita."), - h("li", {}, h("b", {}, "Aspetta"), " quando il vantaggio c'è ma al prezzo attuale costi e incertezza se lo mangiano: l'app indica il prezzo a cui conviene (ordine in attesa)."), - h("li", {}, h("b", {}, "Evita"), " quando il problema non è il prezzo: mercato poco liquido, scadenza troppo lontana, limiti di rischio già pieni."), - h("li", {}, h("b", {}, "Nessuna azione"), " quando la stima è vicina al prezzo; l'app dice comunque a che prezzo comprare SÌ o NO diventerebbe conveniente."), - h("li", {}, h("b", {}, "Tieni / Vendi"), " se hai già una posizione: si vende quando il prezzo raggiunge la stima (incassare subito rende più che aspettare la risoluzione, tenuto conto delle commissioni e del rendimento chiesto al capitale) o quando una nuova previsione gira la stima."), + h("li", {}, h("b", {}, t("Compra SÌ / NO")), t(" con un ordine limite: il prezzo massimo, le quote e la cifra. Appena comprate, un ordine limite di vendita.")), + h("li", {}, h("b", {}, t("Aspetta")), t(" quando il vantaggio c'è ma al prezzo attuale costi e incertezza se lo mangiano: l'app indica il prezzo a cui conviene (ordine in attesa).")), + h("li", {}, h("b", {}, t("Evita")), t(" quando il problema non è il prezzo: mercato poco liquido, scadenza troppo lontana, limiti di rischio già pieni.")), + h("li", {}, h("b", {}, t("Nessuna azione")), t(" quando la stima è vicina al prezzo; l'app dice comunque a che prezzo comprare SÌ o NO diventerebbe conveniente.")), + h("li", {}, h("b", {}, t("Tieni / Vendi")), t(" se hai già una posizione: si vende quando il prezzo raggiunge la stima (incassare subito rende più che aspettare la risoluzione, tenuto conto delle commissioni e del rendimento chiesto al capitale) o quando una nuova previsione gira la stima.")), ), - h("p", { class: "formula" }, "vendi se prezzo − commissione ≥ stima al prezzo attuale ÷ (1 + rendimento richiesto)^(giorni/365)"), - p("Niente stop-loss fisso: se il prezzo scende ma le notizie non cambiano, la quota è ancora più conveniente. Ogni piano elenca i motivi a favore e contro (notizie, margini, tempo, risultati passati) e un livello di fiducia."), + h("p", { class: "formula" }, t("vendi se prezzo − commissione ≥ stima al prezzo attuale ÷ (1 + rendimento richiesto)^(giorni/365)")), + p(t("Niente stop-loss fisso: se il prezzo scende ma le notizie non cambiano, la quota è ancora più conveniente. Ogni piano elenca i motivi a favore e contro (notizie, margini, tempo, risultati passati) e un livello di fiducia.")), ), - stage(10, "Verifica sui risultati", - p("Quando un mercato si risolve, la pagina ", h("a", { href: "#/calibrazione" }, "Calibrazione"), - " confronta l'errore (Brier score) delle previsioni con quello del prezzo; il ", h("a", { href: "#/portafoglio" }, "portafoglio simulato"), - " mostra se, dopo costi e limiti, i segnali avrebbero fatto guadagnare. Se su molti mercati non battono il prezzo, i segnali non vanno seguiti con soldi veri."), + stage(10, t("Verifica sui risultati"), + p(t("Quando un mercato si risolve, la pagina "), h("a", { href: "#/calibrazione" }, t("Calibrazione")), + t(" confronta l'errore (Brier score) delle previsioni con quello del prezzo; il "), h("a", { href: "#/portafoglio" }, t("portafoglio simulato")), + t(" mostra se, dopo costi e limiti, i segnali avrebbero fatto guadagnare. Se su molti mercati non battono il prezzo, i segnali non vanno seguiti con soldi veri.")), ), ), h("section", { class: "card", style: { margin: "16px 0" }, "aria-labelledby": "h-example" }, - h("h2", { id: "h-example" }, "Esempio con numeri"), - h("p", { class: "muted small", style: { marginBottom: "10px" } }, "Numeri inventati per illustrare il calcolo, con i parametri di questo server."), + h("h2", { id: "h-example" }, t("Esempio con numeri")), + h("p", { class: "muted small", style: { marginBottom: "10px" } }, t("Numeri inventati per illustrare il calcolo, con i parametri di questo server.")), h("div", { class: "example-grid" }, - kv("Prezzo SÌ", fmt.cents(ex.market_probability)), - kv("Stima Jev", fmt.pct(ex.model_probability)), - kv("Evidenze", fmt.pct(ex.evidence_strength)), - kv("Peso w", fmt.pct(ex.model_weight)), - kv("Blended", fmt.pct(ex.blended_probability)), - kv("Edge", fmt.pts(ex.edge)), - kv("Puntata", ex.edge >= minEdge && ex.evidence_strength >= minEv ? fmt.pct(exKelly) : "–"), + kv(t("Prezzo SÌ"), fmt.cents(ex.market_probability)), + kv(t("Stima Jev"), fmt.pct(ex.model_probability)), + kv(t("Evidenze"), fmt.pct(ex.evidence_strength)), + kv(t("Peso w"), fmt.pct(ex.model_weight)), + kv(t("Blended"), fmt.pct(ex.blended_probability)), + kv(t("Edge"), fmt.pts(ex.edge)), + kv(t("Puntata"), ex.edge >= minEdge && ex.evidence_strength >= minEv ? fmt.pct(exKelly) : "–"), ), h("p", { class: "secondary", style: { marginTop: "12px" } }, explainSentence({ ...ex, signal: ex.edge >= minEdge && ex.evidence_strength >= minEv ? "BUY_YES" : "HOLD" }, st)), ), h("div", { class: "grid-2" }, h("section", { class: "card", "aria-labelledby": "h-limits" }, - h("h2", { id: "h-limits" }, "Cosa tenere presente"), + h("h2", { id: "h-limits" }, t("Cosa tenere presente")), h("ul", { class: "bullets" }, - h("li", {}, "L'app non piazza ordini: i segnali sono indicazioni da verificare."), - h("li", {}, "Il segnale non considera commissioni e spread: li considera la valutazione economica («Conviene?»), che decide se e quanto puntare."), - h("li", {}, "Il prezzo cambia: una previsione vecchia di ore può non valere più. Controlla sempre il prezzo attuale."), - h("li", {}, "Il collegamento usa i titoli: una notizia importante con un titolo diverso dalla domanda può sfuggire."), - h("li", {}, "Jev può sbagliare, soprattutto con poche notizie o regole di risoluzione complicate. Leggi le regole del mercato."), + h("li", {}, t("L'app non piazza ordini: i segnali sono indicazioni da verificare.")), + h("li", {}, t("Il segnale non considera commissioni e spread: li considera la valutazione economica («Conviene?»), che decide se e quanto puntare.")), + h("li", {}, t("Il prezzo cambia: una previsione vecchia di ore può non valere più. Controlla sempre il prezzo attuale.")), + h("li", {}, t("Il collegamento usa i titoli: una notizia importante con un titolo diverso dalla domanda può sfuggire.")), + h("li", {}, t("Jev può sbagliare, soprattutto con poche notizie o regole di risoluzione complicate. Leggi le regole del mercato.")), ), ), h("section", { class: "card", "aria-labelledby": "h-glossary" }, - h("h2", { id: "h-glossary" }, "Glossario"), + h("h2", { id: "h-glossary" }, t("Glossario")), h("dl", { class: "glossary" }, - [["Prezzo", "price"], ["Stima Jev", "jev"], ["Forza delle evidenze", "evidence"], ["Peso w", "weight"], - ["Blended", "blended"], ["Edge", "edge"], ["Puntata (Kelly)", "kelly"], ["Rilevanza per i mercati", "relevance"], ["Brier score", "brier"]] + [[t("Prezzo"), "price"], [t("Stima Jev"), "jev"], [t("Forza delle evidenze"), "evidence"], [t("Peso w"), "weight"], + [t("Blended"), "blended"], [t("Edge"), "edge"], [t("Puntata (Kelly)"), "kelly"], [t("Rilevanza per i mercati"), "relevance"], [t("Brier score"), "brier"]] .map(([term, key]) => [h("dt", {}, term), h("dd", {}, GLOSSARY[key])]), ), ), diff --git a/frontend/views/multi.js b/frontend/views/multi.js index 320261f..969137c 100644 --- a/frontend/views/multi.js +++ b/frontend/views/multi.js @@ -1,10 +1,11 @@ // "Più esiti": Polymarket events with several mutually exclusive outcomes, shown as a distribution. // Series identity as everywhere: market price = orange bar, Jev = aqua diamond, blended = blue circle. +import { t } from "../i18n.js"; import { h, api, fmt, toast, icon, emptyState, infoTip, externalLink, debounce, withTooltip, ttRows } from "../ui.js"; import { economicsCard, verdictBadge } from "../economics.js"; const filters = { q: "", sort: "volume" }; -const SORTS = [["volume", "Volume"], ["edge", "Edge più alto"], ["signal", "Ultima previsione"], ["end_date", "Scadenza"], ["news", "Notizie collegate"]]; +const SORTS = [["volume", t("Volume")], ["edge", t("Edge più alto")], ["signal", t("Ultima previsione")], ["end_date", t("Scadenza")], ["news", t("Notizie collegate")]]; const eventHref = (id) => `#/multi/${encodeURIComponent(id)}`; const edgeCls = (e) => (e == null ? "" : e > 0 ? "pos" : e < 0 ? "neg" : ""); @@ -14,11 +15,11 @@ function distRow(o, scale, pred, best) { const market = p ? p.market : o.price; const pos = (v) => `${Math.min(100, (v / scale) * 100)}%`; const tip = () => ttRows(o.label, [ - ["market", "Prezzo (normalizzato)", fmt.pct(market)], - ...(p ? [["jev", "Stima Jev", fmt.pct(p.model)], ["blended", "Blended", fmt.pct(p.blended)], [null, "Edge", fmt.pts(p.edge)]] : []), + ["market", t("Prezzo (normalizzato)"), fmt.pct(market)], + ...(p ? [["jev", t("Stima Jev"), fmt.pct(p.model)], ["blended", t("Blended"), fmt.pct(p.blended)], [null, t("Edge"), fmt.pts(p.edge)]] : []), ]); const track = h("div", { class: "dist-track", role: "img", - "aria-label": `${o.label}: prezzo ${fmt.pct(market)}${p ? `, Jev ${fmt.pct(p.model)}, blended ${fmt.pct(p.blended)}` : ""}` }, + "aria-label": t("{0}: prezzo {1}{2}", o.label, fmt.pct(market), p ? t(", Jev {0}, blended {1}", fmt.pct(p.model), fmt.pct(p.blended)) : "") }, h("span", { class: "dist-bar", style: { width: pos(market) } }), p ? h("span", { class: "pmark jev", style: { left: pos(p.model) } }) : null, p ? h("span", { class: "pmark blended", style: { left: pos(p.blended) } }) : null); @@ -32,35 +33,35 @@ function distRow(o, scale, pred, best) { function legend(pred) { return h("div", { class: "legend" }, - h("span", {}, h("span", { class: "key market" }), "Prezzo di mercato (normalizzato a 100%)"), - pred ? h("span", {}, h("span", { class: "key jev" }), "Stima Jev") : null, - pred ? h("span", {}, h("span", { class: "key blended" }), "Blended") : null); + h("span", {}, h("span", { class: "key market" }), t("Prezzo di mercato (normalizzato a 100%)")), + pred ? h("span", {}, h("span", { class: "key jev" }), t("Stima Jev")) : null, + pred ? h("span", {}, h("span", { class: "key blended" }), t("Blended")) : null); } const isBuy = (pred) => pred?.signal === "BUY_YES" || pred?.signal === "BUY_NO"; -const sideLabel = (signal) => (signal === "BUY_NO" ? "NO" : "SÌ"); +const sideLabel = (signal) => (signal === "BUY_NO" ? "NO" : t("SÌ")); /** Guaranteed-profit gap: one share of every outcome costs less than it surely pays. */ function arbitrageBadge(arb) { if (!arb) return null; - const what = arb.kind === "buy_all_yes" ? "comprando il SÌ di tutti gli esiti" : "comprando il NO di tutti gli esiti"; - return h("span", { class: "badge badge-warning", title: `Costo ${fmt.cents(arb.cost)} per un set che paga ${fmt.money(arb.payout)} in ogni caso, ${what}, commissioni incluse. Solo al miglior prezzo del book: la quantità disponibile può essere piccola e il prezzo cambiare in fretta.` }, - icon("alert"), `Arbitraggio ${fmt.pts(arb.profit)} per set`); + const what = arb.kind === "buy_all_yes" ? t("comprando il SÌ di tutti gli esiti") : t("comprando il NO di tutti gli esiti"); + return h("span", { class: "badge badge-warning", title: t("Costo {0} per un set che paga {1} in ogni caso, {2}, commissioni incluse. Solo al miglior prezzo del book: la quantità disponibile può essere piccola e il prezzo cambiare in fretta.", fmt.cents(arb.cost), fmt.money(arb.payout), what) }, + icon("alert"), t("Arbitraggio {0} per set", fmt.pts(arb.profit))); } function signalLine(pred, outcomes) { - if (!pred) return h("span", { class: "muted small" }, "Nessuna previsione"); + if (!pred) return h("span", { class: "muted small" }, t("Nessuna previsione")); const best = pred.outcomes.find((o) => o.id === pred.best_outcome_id) || outcomes.find((o) => o.id === pred.best_outcome_id); if (isBuy(pred) && best) { const ev = pred.economics?.[best.id]; const no = pred.signal === "BUY_NO"; return h("span", { class: "signal-line" }, h("span", { class: `badge ${no ? "badge-critical" : "badge-good"}` }, icon(no ? "down" : "up"), - `Compra ${sideLabel(pred.signal)} su ${best.label} · ${fmt.pts(pred.best_edge)}`), + t("Compra {0} su {1} · {2}", sideLabel(pred.signal), best.label, fmt.pts(pred.best_edge))), ev ? verdictBadge(ev.verdict) : null, - ev && ev.verdict !== "NO" ? h("span", { class: "muted small" }, `${fmt.money(ev.outlay)} a max ${fmt.cents(ev.limit_price)}`) : null); + ev && ev.verdict !== "NO" ? h("span", { class: "muted small" }, t("{0} a max {1}", fmt.money(ev.outlay), fmt.cents(ev.limit_price))) : null); } - return h("span", { class: "badge" }, icon("pause"), "Attendi"); + return h("span", { class: "badge" }, icon("pause"), t("Attendi")); } // ---------- List ---------- @@ -70,25 +71,25 @@ export async function viewMultiList(ctx) { const summary = h("p", { class: "muted small", role: "status" }); const load = async () => { try { - const data = await api("/multi", { params: { q: filters.q || undefined, sort: filters.sort, limit: 60 } }); - summary.textContent = `${fmt.count(data.events.length, "evento", "eventi")}${data.total > data.events.length ? ` su ${fmt.int(data.total)}` : ""}`; - list.replaceChildren(...(data.events.length ? data.events.map(eventCard) : [emptyState("Nessun evento", - filters.q ? "Nessun evento o esito corrisponde alla ricerca." : "Gli eventi a più esiti arrivano con l'aggiornamento dei mercati.")])); + const data = localizeOther(await api("/multi", { params: { q: filters.q || undefined, sort: filters.sort, limit: 60 } })); + summary.textContent = `${fmt.count(data.events.length, t("evento"), t("eventi"))}${data.total > data.events.length ? t(" su {0}", fmt.int(data.total)) : ""}`; + list.replaceChildren(...(data.events.length ? data.events.map(eventCard) : [emptyState(t("Nessun evento"), + filters.q ? t("Nessun evento o esito corrisponde alla ricerca.") : t("Gli eventi a più esiti arrivano con l'aggiornamento dei mercati."))])); } catch (e) { - list.replaceChildren(emptyState("Impossibile caricare gli eventi", e.message)); + list.replaceChildren(emptyState(t("Impossibile caricare gli eventi"), e.message)); } }; - const search = h("input", { id: "mx-search", class: "search", type: "search", placeholder: "Cerca un evento o un esito (es. elezioni, Arsenal)", value: filters.q, "aria-label": "Cerca un evento o un esito" }); + const search = h("input", { id: "mx-search", class: "search", type: "search", placeholder: t("Cerca un evento o un esito (es. elezioni, Arsenal)"), value: filters.q, "aria-label": t("Cerca un evento o un esito") }); search.addEventListener("input", debounce(() => { filters.q = search.value.trim(); load(); }, 300)); const sort = h("select", { id: "mx-sort", class: "select" }, SORTS.map(([v, l]) => h("option", { value: v, selected: v === filters.sort }, l))); sort.addEventListener("change", () => { filters.sort = sort.value; load(); }); await load(); return h("div", {}, - ctx.pageHead("Mercati a più esiti", - "Eventi con più risposte possibili, di cui una sola vincerà (elezioni, campionati, premi). Ogni esito è una quota SÌ/NO; qui si leggono insieme, come una distribuzione di probabilità."), - h("div", { class: "filters", role: "group", "aria-label": "Filtri e ordinamento" }, + ctx.pageHead(t("Mercati a più esiti"), + t("Eventi con più risposte possibili, di cui una sola vincerà (elezioni, campionati, premi). Ogni esito è una quota SÌ/NO; qui si leggono insieme, come una distribuzione di probabilità.")), + h("div", { class: "filters", role: "group", "aria-label": t("Filtri e ordinamento") }, h("div", { class: "search-wrap" }, icon("search"), search), - h("label", { class: "field", for: "mx-sort" }, "Ordina per", sort)), + h("label", { class: "field", for: "mx-sort" }, t("Ordina per"), sort)), summary, list); } @@ -100,21 +101,21 @@ export function eventCard(e) { return h("article", { class: "card multi-card" }, h("h2", { class: "multi-title" }, h("a", { href: eventHref(e.id) }, e.title)), h("div", { class: "meta-row small" }, - h("span", {}, `Scade ${fmt.date(e.end_date)}`), h("span", {}, `Volume ${fmt.usd(e.volume)}`), - h("span", {}, fmt.count(e.outcome_count, "esito", "esiti")), h("span", {}, fmt.count(e.linked_articles, "notizia", "notizie"))), + h("span", {}, t("Scade {0}", fmt.date(e.end_date))), h("span", {}, t("Volume {0}", fmt.usd(e.volume))), + h("span", {}, fmt.count(e.outcome_count, t("esito"), t("esiti"))), h("span", {}, fmt.count(e.linked_articles, t("notizia"), t("notizie")))), arbitrageBadge(e.arbitrage), h("div", { class: "dist" }, shown.map((o) => distRow(o, scale, pred, pred?.best_outcome_id === o.id && isBuy(pred)))), - e.outcome_count > shown.length ? h("p", { class: "muted small" }, `+ ${fmt.count(e.outcome_count - shown.length, "altro esito", "altri esiti")}`) : null, + e.outcome_count > shown.length ? h("p", { class: "muted small" }, `+ ${fmt.count(e.outcome_count - shown.length, t("altro esito"), t("altri esiti"))}`) : null, h("div", { class: "multi-foot" }, signalLine(pred, e.outcomes), pred ? h("span", { class: "muted small", title: fmt.dateTime(pred.created_at) }, fmt.ago(pred.created_at)) : null, - h("a", { class: "btn btn-ghost btn-sm", href: eventHref(e.id) }, "Dettaglio")), + h("a", { class: "btn btn-ghost btn-sm", href: eventHref(e.id) }, t("Dettaglio"))), ); } // ---------- Detail ---------- export async function viewMultiDetail(ctx, id) { - const e = await api(`/multi/${encodeURIComponent(id)}`); + const e = localizeOther(await api(`/multi/${encodeURIComponent(id)}`)); const status = ctx.status; const pred = e.latest_prediction; const rows = pred @@ -123,19 +124,19 @@ export async function viewMultiDetail(ctx, id) { const scale = Math.max(0.1, ...rows.flatMap((r) => [r.market, r.model, r.blended]).filter((v) => v != null)) * 1.1; const predictBtn = h("button", { class: "btn btn-primary", type: "button" }, h("span", { class: "spinner", "aria-hidden": "true" }), icon("refresh"), - pred ? "Nuova previsione" : "Chiedi una previsione a Jev"); + pred ? t("Nuova previsione") : t("Chiedi una previsione a Jev")); let blocker = null; - if (!ctx.isAdmin()) blocker = "Solo gli amministratori possono chiedere previsioni (sono chiamate a pagamento)."; - else if (!status?.jev_enabled) blocker = "Serve TYPESAFE_API_KEY nel file .env."; - else if (e.closed) blocker = "L'evento è chiuso."; - else if (!e.evidence.length) blocker = "Nessuna notizia recente collegata a questo evento."; + if (!ctx.isAdmin()) blocker = t("Solo gli amministratori possono chiedere previsioni (sono chiamate a pagamento)."); + else if (!status?.jev_enabled) blocker = t("Serve TYPESAFE_API_KEY nel file .env."); + else if (e.closed) blocker = t("L'evento è chiuso."); + else if (!e.evidence.length) blocker = t("Nessuna notizia recente collegata a questo evento."); predictBtn.disabled = Boolean(blocker); predictBtn.addEventListener("click", async () => { ctx.setBusy(predictBtn, true); try { - const p = await api(`/multi/${encodeURIComponent(id)}/predict`, { method: "POST" }); + const p = localizeOther(await api(`/multi/${encodeURIComponent(id)}/predict`, { method: "POST" })); const best = p.outcomes.find((o) => o.id === p.best_outcome_id); - toast(isBuy(p) ? `Previsione salvata: compra ${sideLabel(p.signal)} su ${best.label} (${fmt.pts(p.best_edge)})` : "Previsione salvata: nessun esito abbastanza lontano dal suo prezzo"); + toast(isBuy(p) ? t("Previsione salvata: compra {0} su {1} ({2})", sideLabel(p.signal), best.label, fmt.pts(p.best_edge)) : t("Previsione salvata: nessun esito abbastanza lontano dal suo prezzo")); ctx.rerender(); } catch (err) { toast(err.message, { error: true }); @@ -144,66 +145,66 @@ export async function viewMultiDetail(ctx, id) { }); const winner = e.winner_id ? e.outcomes.find((o) => o.id === e.winner_id) : null; - const table = h("details", { class: "chart-table" }, h("summary", {}, "Mostra tabella"), + const table = h("details", { class: "chart-table" }, h("summary", {}, t("Mostra tabella")), h("div", { class: "table-wrap" }, h("table", { class: "compact-table" }, - h("thead", {}, h("tr", {}, ...["Esito", "Quota SÌ", "Prezzo normalizzato", "Jev", "Blended", "Edge"].map((t, i) => h("th", { scope: "col", class: i ? "num" : null }, t)))), + h("thead", {}, h("tr", {}, ...[t("Esito"), t("Quota SÌ"), t("Prezzo normalizzato"), t("Jev"), t("Blended"), t("Edge")].map((t, i) => h("th", { scope: "col", class: i ? "num" : null }, t)))), h("tbody", {}, rows.map((r) => h("tr", {}, h("th", { scope: "row" }, r.label), h("td", { class: "num" }, fmt.cents(r.price ?? r.market)), h("td", { class: "num" }, fmt.pct(r.market)), h("td", { class: "num" }, fmt.pct(r.model)), h("td", { class: "num" }, fmt.pct(r.blended)), h("td", { class: `num ${edgeCls(r.edge)}` }, r.edge == null ? "–" : fmt.pts(r.edge)))))))); return h("div", {}, - h("a", { class: "back", href: "#/multi" }, icon("back"), "Tutti gli eventi"), + h("a", { class: "back", href: "#/multi" }, icon("back"), t("Tutti gli eventi")), h("div", { class: "card", style: { marginBottom: "16px" } }, - h("div", { class: "eyebrow" }, "Evento Polymarket a più esiti"), + h("div", { class: "eyebrow" }, t("Evento Polymarket a più esiti")), h("h1", { style: { marginTop: "4px" } }, e.title), h("div", { class: "meta-row" }, - h("span", { class: `badge ${e.closed ? "" : "badge-outline"}` }, e.closed ? "Chiuso" : "Aperto"), - winner ? h("span", { class: "badge badge-good" }, icon("check"), `Ha vinto: ${winner.label}`) : null, - h("span", {}, `Scade ${fmt.date(e.end_date)}`), h("span", {}, `Volume ${fmt.usd(e.volume)}`), - h("span", {}, fmt.count(e.outcome_count, "esito", "esiti")), + h("span", { class: `badge ${e.closed ? "" : "badge-outline"}` }, e.closed ? t("Chiuso") : t("Aperto")), + winner ? h("span", { class: "badge badge-good" }, icon("check"), t("Ha vinto: {0}", winner.label)) : null, + h("span", {}, t("Scade {0}", fmt.date(e.end_date))), h("span", {}, t("Volume {0}", fmt.usd(e.volume))), + h("span", {}, fmt.count(e.outcome_count, t("esito"), t("esiti"))), arbitrageBadge(e.arbitrage), - e.url ? externalLink(e.url, "Apri su Polymarket") : null)), + e.url ? externalLink(e.url, t("Apri su Polymarket")) : null)), h("section", { class: "card", "aria-labelledby": "h-mx-dist", style: { marginBottom: "16px" } }, h("div", { class: "card-head" }, - h("h2", { id: "h-mx-dist" }, pred ? "Distribuzione: mercato e Jev" : "Distribuzione secondo il mercato"), + h("h2", { id: "h-mx-dist" }, pred ? t("Distribuzione: mercato e Jev") : t("Distribuzione secondo il mercato")), signalLine(pred, e.outcomes)), h("p", { class: "muted small" }, pred - ? `Jev ha letto ${fmt.count(pred.article_count, "notizia", "notizie")} e ha stimato la probabilità di ogni esito senza vedere i prezzi. Evidenze ${fmt.pct(pred.evidence_strength)}: la sua stima pesa per il ${fmt.pct(pred.model_weight)}. Previsione ${fmt.ago(pred.created_at)}. ` - : "I prezzi delle quote SÌ sono normalizzati in modo che la somma faccia 100%. ", - infoTip("Edge = blended − prezzo della quota SÌ (quello che si paga davvero). Il segnale indica l'esito più lontano dal suo prezzo, se l'edge supera la soglia minima e le notizie sono abbastanza forti: SÌ se è sottovalutato, NO se è sopravvalutato (spesso un favorito su cui il mercato è troppo ottimista). Jev valuta i 12 esiti più probabili; gli altri sono sommati in «Altri esiti».", "Come si legge?")), + ? t("Jev ha letto {0} e ha stimato la probabilità di ogni esito senza vedere i prezzi. Evidenze {1}: la sua stima pesa per il {2}. Previsione {3}. ", fmt.count(pred.article_count, t("notizia"), t("notizie")), fmt.pct(pred.evidence_strength), fmt.pct(pred.model_weight), fmt.ago(pred.created_at)) + : t("I prezzi delle quote SÌ sono normalizzati in modo che la somma faccia 100%. "), + infoTip(t("Edge = blended − prezzo della quota SÌ (quello che si paga davvero). Il segnale indica l'esito più lontano dal suo prezzo, se l'edge supera la soglia minima e le notizie sono abbastanza forti: SÌ se è sottovalutato, NO se è sopravvalutato (spesso un favorito su cui il mercato è troppo ottimista). Jev valuta i 12 esiti più probabili; gli altri sono sommati in «Altri esiti»."), t("Come si legge?"))), legend(pred), h("div", { class: "dist dist-full" }, rows.map((r) => distRow({ id: r.id, label: r.label, price: r.market }, scale, pred, isBuy(pred) && pred.best_outcome_id === r.id))), table, - h("div", { class: "predict-bar" }, predictBtn, h("span", { class: "muted small" }, blocker || "Una chiamata all'API TypeSafe per tutto l'evento."))), + h("div", { class: "predict-bar" }, predictBtn, h("span", { class: "muted small" }, blocker || t("Una chiamata all'API TypeSafe per tutto l'evento.")))), pred ? economicsSection(ctx, pred) : null, h("section", { class: "card", "aria-labelledby": "h-mx-news", style: { marginBottom: "16px" } }, - h("div", { class: "card-head" }, h("h2", { id: "h-mx-news" }, "Notizie collegate"), - h("span", { class: "muted small" }, "Una per storia, dalla più utile")), + h("div", { class: "card-head" }, h("h2", { id: "h-mx-news" }, t("Notizie collegate")), + h("span", { class: "muted small" }, t("Una per storia, dalla più utile"))), e.evidence.length ? h("div", {}, e.evidence.map((ev) => h("div", { class: "ev" }, h("div", {}, externalLink(ev.url, h("span", { class: "ev-title" }, ev.title)), h("div", { class: "article-meta", style: { margin: "4px 0 0" } }, h("span", {}, ev.source_name), h("span", { title: fmt.dateTime(ev.published_at) }, fmt.ago(ev.published_at)), - ev.corroboration > 1 ? h("span", { class: "badge badge-outline" }, `${ev.corroboration} fonti`) : null), - ev.matched_terms.length ? h("div", { class: "terms" }, ev.matched_terms.map((t) => h("span", { class: "term" }, t))) : null), + ev.corroboration > 1 ? h("span", { class: "badge badge-outline" }, t("{0} fonti", ev.corroboration)) : null), + ev.matched_terms.length ? h("div", { class: "terms" }, ev.matched_terms.map((x) => h("span", { class: "term" }, x))) : null), h("div", { class: "ev-stats" }, - h("span", {}, "Pertinenza ", h("b", { class: "mono" }, fmt.pct(ev.match_score))), - ev.relevance != null ? h("span", {}, "Rilevanza ", h("b", { class: "mono" }, fmt.pct(ev.relevance))) : null, - ev.favours ? h("span", { class: "badge badge-outline" }, icon("up"), `Favorisce ${ev.favours}`) : null), - ))) : h("p", { class: "secondary" }, "Nessuna notizia recente riguarda questo evento.")), + h("span", {}, t("Pertinenza "), h("b", { class: "mono" }, fmt.pct(ev.match_score))), + ev.relevance != null ? h("span", {}, t("Rilevanza "), h("b", { class: "mono" }, fmt.pct(ev.relevance))) : null, + ev.favours ? h("span", { class: "badge badge-outline" }, icon("up"), t("Favorisce {0}", ev.favours)) : null), + ))) : h("p", { class: "secondary" }, t("Nessuna notizia recente riguarda questo evento."))), e.predictions.length > 1 ? h("section", { class: "card", "aria-labelledby": "h-mx-hist", style: { marginBottom: "16px" } }, - h("h2", { id: "h-mx-hist", style: { marginBottom: "10px" } }, "Storico delle previsioni"), + h("h2", { id: "h-mx-hist", style: { marginBottom: "10px" } }, t("Storico delle previsioni")), h("div", { class: "table-wrap" }, h("table", { class: "compact-table" }, - h("thead", {}, h("tr", {}, ...["Quando", "Esito più lontano dal prezzo", "Edge", "Evidenze", "Segnale"].map((t, i) => h("th", { scope: "col", class: i === 2 || i === 3 ? "num" : null }, t)))), + h("thead", {}, h("tr", {}, ...[t("Quando"), t("Esito più lontano dal prezzo"), t("Edge"), t("Evidenze"), t("Segnale")].map((t, i) => h("th", { scope: "col", class: i === 2 || i === 3 ? "num" : null }, t)))), h("tbody", {}, e.predictions.map((p) => { const best = p.outcomes.find((o) => o.id === p.best_outcome_id); return h("tr", {}, h("td", {}, fmt.dateTime(p.created_at)), h("td", {}, best?.label || "–"), h("td", { class: `num ${edgeCls(p.best_edge)}` }, fmt.pts(p.best_edge)), h("td", { class: "num" }, fmt.pct(p.evidence_strength)), - h("td", {}, isBuy(p) ? `Compra ${sideLabel(p.signal)}` : "Attendi")); + h("td", {}, isBuy(p) ? t("Compra {0}", sideLabel(p.signal)) : t("Attendi"))); }))))) : null, - e.description ? h("details", { class: "card rules" }, h("summary", {}, "Regole di risoluzione"), h("p", { class: "secondary", style: { whiteSpace: "pre-line", marginTop: "10px" } }, e.description)) : null, + e.description ? h("details", { class: "card rules" }, h("summary", {}, t("Regole di risoluzione")), h("p", { class: "secondary", style: { whiteSpace: "pre-line", marginTop: "10px" } }, e.description)) : null, ); } @@ -216,13 +217,26 @@ function economicsSection(ctx, pred) { const holder = h("div", {}); const paint = () => { const o = candidates.find((c) => c.id === selected); - holder.replaceChildren(economicsCard(ctx, { id: o.id, question: `${o.label} (${o.edge > 0 ? "SÌ" : "NO"})` })); + holder.replaceChildren(economicsCard(ctx, { id: o.id, question: `${o.label} (${o.edge > 0 ? t("SÌ") : "NO"})` })); }; const select = h("select", { id: "mx-outcome", class: "select", style: { minWidth: "260px", maxWidth: "100%" } }, - candidates.map((o) => h("option", { value: o.id, selected: o.id === selected }, `${o.label} · ${o.edge > 0 ? "SÌ" : "NO"} · edge ${fmt.pts(o.edge)}`))); + candidates.map((o) => h("option", { value: o.id, selected: o.id === selected }, t("{0} · {1} · edge {2}", o.label, o.edge > 0 ? t("SÌ") : "NO", fmt.pts(o.edge))))); select.addEventListener("change", () => { selected = select.value; paint(); }); paint(); return h("div", { style: { marginBottom: "16px" } }, - h("label", { class: "field", for: "mx-outcome", style: { marginBottom: "8px" } }, "Esito da valutare", select), + h("label", { class: "field", for: "mx-outcome", style: { marginBottom: "8px" } }, t("Esito da valutare"), select), holder); } + +/** The "other outcomes" bucket is labelled by the server when the forecast is made: show it in the UI language. */ +function localizeOther(value) { + if (Array.isArray(value)) value.forEach(localizeOther); + else if (value && typeof value === "object") { + if (value.id === "other" && typeof value.label === "string") { + const n = value.label.match(/\((\d+)\)/); + value.label = n ? t("Altri esiti ({0})", n[1]) : t("Altri esiti"); + } + Object.values(value).forEach((v) => { if (v && typeof v === "object") localizeOther(v); }); + } + return value; +} diff --git a/frontend/views/news.js b/frontend/views/news.js index 2d5e5ab..3ad5fa4 100644 --- a/frontend/views/news.js +++ b/frontend/views/news.js @@ -1,4 +1,5 @@ // Notizie: full-text search, filters and the ranked list. +import { t } from "../i18n.js"; import { h, clear, api, fmt, toast, debounce, icon, externalLink, meter, emptyState, infoTip, highlight, selectField, CATEGORY_LABELS, REGION_LABELS, GLOSSARY, @@ -10,7 +11,7 @@ const DEFAULTS = { w_authority: 0.35, w_tech: 0.25, w_urgency: 0.25, w_clickbait: 0.4, max_clickbait: 1, min_authority: 0, }; const state = { ...DEFAULTS }; -const PERIODS = [["", "Qualsiasi data"], ["24", "Ultime 24 ore"], ["72", "Ultimi 3 giorni"], ["168", "Ultimi 7 giorni"], ["720", "Ultimi 30 giorni"]]; +const PERIODS = [["", t("Qualsiasi data")], ["24", t("Ultime 24 ore")], ["72", t("Ultimi 3 giorni")], ["168", t("Ultimi 7 giorni")], ["720", t("Ultimi 30 giorni")]]; function syncHash() { // Keep the search in the address so it survives a reload and can be shared @@ -51,15 +52,15 @@ export async function viewNews(ctx, query = {}) { list.append(...data.articles.map((a) => articleCard(a))); offset += data.articles.length; summary.textContent = !data.total ? "" - : state.q ? `${fmt.count(data.total, "risultato", "risultati")} per «${state.q}»${offset < data.total ? `, mostrati ${fmt.int(offset)}` : ""}` - : `${fmt.int(offset)} di ${fmt.count(data.total, "notizia", "notizie")}`; + : state.q ? t("{0} per «{1}»{2}", fmt.count(data.total, t("risultato"), t("risultati")), state.q, offset < data.total ? t(", mostrati {0}", fmt.int(offset)) : "") + : t("{0} di {1}", fmt.int(offset), fmt.count(data.total, t("notizia"), t("notizie"))); if (!data.total) { list.replaceChildren(state.q || filtersActive() - ? emptyState("Nessuna notizia trovata", "Prova con meno parole, un periodo più lungo o meno filtri.", - h("button", { class: "btn btn-ghost", type: "button", on: { click: resetFilters } }, "Azzera ricerca e filtri")) - : emptyState("Nessuna notizia", ctx.isAdmin() ? "Premi «Aggiorna notizie» per scaricarle dalle fonti attive." : "Le notizie compaiono dopo il prossimo aggiornamento automatico.")); + ? emptyState(t("Nessuna notizia trovata"), t("Prova con meno parole, un periodo più lungo o meno filtri."), + h("button", { class: "btn btn-ghost", type: "button", on: { click: resetFilters } }, t("Azzera ricerca e filtri"))) + : emptyState(t("Nessuna notizia"), ctx.isAdmin() ? t("Premi «Aggiorna notizie» per scaricarle dalle fonti attive.") : t("Le notizie compaiono dopo il prossimo aggiornamento automatico."))); } - more.replaceChildren(offset < data.total ? h("button", { class: "btn btn-ghost", type: "button", on: { click: () => load(false) } }, "Carica altre") : ""); + more.replaceChildren(offset < data.total ? h("button", { class: "btn btn-ghost", type: "button", on: { click: () => load(false) } }, t("Carica altre")) : ""); }; const reload = () => load(true).catch((e) => toast(e.message, { error: true })); const reloadSoon = debounce(reload, 300); @@ -74,8 +75,8 @@ export async function viewNews(ctx, query = {}) { // Search box const input = h("input", { id: "n-q", class: "search search-lg", type: "search", value: state.q, autocomplete: "off", maxlength: "200", - placeholder: "Cerca nelle notizie: parole, \"frase esatta\", -escludi", - "aria-label": "Cerca nelle notizie", "aria-describedby": "n-q-help", + placeholder: t("Cerca nelle notizie: parole, \"frase esatta\", -escludi"), + "aria-label": t("Cerca nelle notizie"), "aria-describedby": "n-q-help", }); input.addEventListener("input", () => { state.q = input.value.trim(); @@ -85,52 +86,52 @@ export async function viewNews(ctx, query = {}) { }); input.addEventListener("keydown", (e) => { if (e.key === "Escape" && input.value) { input.value = ""; input.dispatchEvent(new Event("input")); } }); - const sortOptions = [["", "Ordinamento automatico"], ["relevance", "Più pertinenti"], ["score", "Punteggio più alto"], ["recent", "Più recenti"]]; - const sortField = selectField("n-sort", "Ordina", sortOptions, state.sort, (v) => { state.sort = v; reload(); }); + const sortOptions = [["", t("Ordinamento automatico")], ["relevance", t("Più pertinenti")], ["score", t("Punteggio più alto")], ["recent", t("Più recenti")]]; + const sortField = selectField("n-sort", t("Ordina"), sortOptions, state.sort, (v) => { state.sort = v; reload(); }); const sortSelect = sortField.querySelector("select"); sortSelect.querySelector('option[value="relevance"]').disabled = !state.q; const searchBar = h("div", { class: "searchbar" }, h("div", { class: "search-wrap search-wrap-lg" }, icon("search"), input), - selectField("n-scope", "Cerca in", [["all", "Titolo, testo e riassunto"], ["title", "Solo titolo"]], state.scope, (v) => { state.scope = v; if (state.q) reload(); }), + selectField("n-scope", t("Cerca in"), [["all", t("Titolo, testo e riassunto")], ["title", t("Solo titolo")]], state.scope, (v) => { state.scope = v; if (state.q) reload(); }), ); - const categoryChips = h("div", { class: "chips", role: "group", "aria-label": "Categoria" }); + const categoryChips = h("div", { class: "chips", role: "group", "aria-label": t("Categoria") }); const total = categories.reduce((a, c) => a + c.count, 0); const paintChips = () => categoryChips.replaceChildren( ...[{ name: "", count: total }, ...categories].map((c) => h("button", { class: "chip", type: "button", "aria-pressed": String(state.category === c.name), on: { click: () => { state.category = c.name; paintChips(); reload(); } }, - }, c.name ? CATEGORY_LABELS[c.name] || c.name : "Tutte", h("span", { class: "count" }, fmt.int(c.count)))), + }, c.name ? CATEGORY_LABELS[c.name] || c.name : t("Tutte"), h("span", { class: "count" }, fmt.int(c.count)))), ); paintChips(); const filters = h("div", { class: "filter-row" }, - selectField("n-source", "Fonte", [["", "Tutte le fonti"], ...sources.map((s) => [s.id, s.name])], state.source_id, (v) => { state.source_id = v; reload(); }), - selectField("n-period", "Periodo", PERIODS, state.since_hours, (v) => { state.since_hours = v; reload(); }), - selectField("n-region", "Regione", [["", "Tutte"], ...Object.entries(REGION_LABELS)], state.region, (v) => { state.region = v; reload(); }), + selectField("n-source", t("Fonte"), [["", t("Tutte le fonti")], ...sources.map((s) => [s.id, s.name])], state.source_id, (v) => { state.source_id = v; reload(); }), + selectField("n-period", t("Periodo"), PERIODS, state.since_hours, (v) => { state.since_hours = v; reload(); }), + selectField("n-region", t("Regione"), [["", t("Tutte")], ...Object.entries(REGION_LABELS)], state.region, (v) => { state.region = v; reload(); }), sortField, - h("span", { class: "field" }, ctx.checkField("n-relevant", "Solo rilevanti per i mercati", state.relevant, (v) => { state.relevant = v; reload(); }), infoTip(GLOSSARY.relevance)), - ctx.checkField("n-opinion", "Escludi opinioni", state.hide_opinion, (v) => { state.hide_opinion = v; reload(); }), + h("span", { class: "field" }, ctx.checkField("n-relevant", t("Solo rilevanti per i mercati"), state.relevant, (v) => { state.relevant = v; reload(); }), infoTip(GLOSSARY.relevance)), + ctx.checkField("n-opinion", t("Escludi opinioni"), state.hide_opinion, (v) => { state.hide_opinion = v; reload(); }), ); - const weight = (key, label) => ctx.rangeField(`w-${key}`, label, { min: 0, max: 1, step: 0.05, value: state[key], format: (v) => v.toFixed(2).replace(".", ",") }, + const weight = (key, label) => ctx.rangeField(`w-${key}`, label, { min: 0, max: 1, step: 0.05, value: state[key], format: (v) => fmt.dec(v, 2) }, (v) => { state[key] = v; reloadSoon(); }); const tuning = h("details", { class: "tuning" }, - h("summary", {}, "Personalizza il punteggio"), + h("summary", {}, t("Personalizza il punteggio")), h("div", { class: "tuning-grid" }, - weight("w_authority", "Autorevolezza"), weight("w_tech", "Profondità"), weight("w_urgency", "Urgenza"), weight("w_clickbait", "Penalità clickbait"), - weight("max_clickbait", "Clickbait massimo"), weight("min_authority", "Autorevolezza minima"), + weight("w_authority", t("Autorevolezza")), weight("w_tech", t("Profondità")), weight("w_urgency", t("Urgenza")), weight("w_clickbait", t("Penalità clickbait")), + weight("max_clickbait", t("Clickbait massimo")), weight("min_authority", t("Autorevolezza minima")), ), ); await load(true); requestAnimationFrame(() => { if (state.q) input.focus(); }); return h("div", {}, - ctx.pageHead("Notizie", "Riassunte e classificate da Jev. Le notizie quasi identiche di più testate sono raggruppate."), + ctx.pageHead(t("Notizie"), t("Riassunte e classificate da Jev. Le notizie quasi identiche di più testate sono raggruppate.")), h("div", { class: "filters news-filters" }, searchBar, - h("p", { id: "n-q-help", class: "field-hint" }, "Tutte le parole devono comparire. Usa le virgolette per una frase esatta, «-parola» per escludere, «or» per alternative."), + h("p", { id: "n-q-help", class: "field-hint" }, t("Tutte le parole devono comparire. Usa le virgolette per una frase esatta, «-parola» per escludere, «or» per alternative.")), filters, categoryChips, tuning, ), h("div", { class: "stack" }, summary, list, more), @@ -149,23 +150,23 @@ function articleCard(a) { h("span", { title: fmt.dateTime(a.published_at) }, fmt.ago(a.published_at)), a.category ? h("span", { class: "badge", - title: a.classifier === "jev" && a.category_confidence != null ? `Classificata da Jev, confidenza ${fmt.pct(a.category_confidence)}` : "Classificata con l'euristica locale", + title: a.classifier === "jev" && a.category_confidence != null ? t("Classificata da Jev, confidenza {0}", fmt.pct(a.category_confidence)) : t("Classificata con l'euristica locale"), }, categoryLabel) : null, a.region && a.region !== "Global" ? h("span", { class: "badge badge-outline" }, REGION_LABELS[a.region] || a.region) : null, - relevant ? h("span", { class: "badge badge-accent", title: GLOSSARY.relevance }, icon("up"), "Rilevante per i mercati") : null, - a.is_opinion != null && a.is_opinion >= 0.5 ? h("span", { class: "badge badge-outline" }, "Opinione") : null, - a.cluster_source_count > 1 ? h("span", { class: "badge badge-outline" }, icon("news"), `${a.cluster_source_count} fonti`) : null, + relevant ? h("span", { class: "badge badge-accent", title: GLOSSARY.relevance }, icon("up"), t("Rilevante per i mercati")) : null, + a.is_opinion != null && a.is_opinion >= 0.5 ? h("span", { class: "badge badge-outline" }, t("Opinione")) : null, + a.cluster_source_count > 1 ? h("span", { class: "badge badge-outline" }, icon("news"), t("{0} fonti", a.cluster_source_count)) : null, ), a.snippet ? h("p", { class: "article-summary" }, highlight(a.snippet)) : a.summary ? h("p", { class: "article-summary" }, a.summary) : null, ), h("div", { class: "scores" }, - h("div", { class: "composite" }, h("span", { class: "fig-label" }, "Punteggio"), h("b", {}, fmt.pct(a.composite_score))), - scoreRow("Autorevolezza", a.authority_score, "Quanto è verificata e ben sostenuta da fonti"), - scoreRow("Profondità", a.technical_depth_score, "Livello di analisi tecnica o quantitativa"), - scoreRow("Urgenza", a.urgency_score, "Quanto è una notizia in evoluzione"), - scoreRow("Clickbait", a.clickbait_score, "Sensazionalismo del titolo (più basso è meglio)"), - a.market_relevance != null ? scoreRow("Per i mercati", a.market_relevance, GLOSSARY.relevance) : null, + h("div", { class: "composite" }, h("span", { class: "fig-label" }, t("Punteggio")), h("b", {}, fmt.pct(a.composite_score))), + scoreRow(t("Autorevolezza"), a.authority_score, t("Quanto è verificata e ben sostenuta da fonti")), + scoreRow(t("Profondità"), a.technical_depth_score, t("Livello di analisi tecnica o quantitativa")), + scoreRow(t("Urgenza"), a.urgency_score, t("Quanto è una notizia in evoluzione")), + scoreRow(t("Clickbait"), a.clickbait_score, t("Sensazionalismo del titolo (più basso è meglio)")), + a.market_relevance != null ? scoreRow(t("Per i mercati"), a.market_relevance, GLOSSARY.relevance) : null, ), ); } diff --git a/frontend/views/portfolio.js b/frontend/views/portfolio.js index a782db7..b8ca0c6 100644 --- a/frontend/views/portfolio.js +++ b/frontend/views/portfolio.js @@ -1,12 +1,13 @@ // Portafoglio simulato: results, equity curve, positions, preset and exclusions. +import { t } from "../i18n.js"; import { h, api, fmt, toast, icon, statTile, emptyState, infoTip, CATEGORY_LABELS } from "../ui.js"; import { equityChart } from "../charts.js"; const money = fmt.money; -const sideBadge = (side) => h("span", { class: `badge ${side === "YES" ? "badge-accent" : "badge-outline"}` }, side === "YES" ? "SÌ" : "NO"); +const sideBadge = (side) => h("span", { class: `badge ${side === "YES" ? "badge-accent" : "badge-outline"}` }, side === "YES" ? t("SÌ") : "NO"); const STATUS = { - won: ["badge-good", "check", "Vinta"], lost: ["badge-critical", "x", "Persa"], - void: ["badge-outline", "minus", "Annullata 50-50"], sold: ["badge-accent", "down", "Venduta"], open: ["badge-outline", "pause", "Aperta"], excluded: ["", "minus", "Esclusa"], + won: ["badge-good", "check", t("Vinta")], lost: ["badge-critical", "x", t("Persa")], + void: ["badge-outline", "minus", t("Annullata 50-50")], sold: ["badge-accent", "down", t("Venduta")], open: ["badge-outline", "pause", t("Aperta")], excluded: ["", "minus", t("Esclusa")], }; const statusBadge = (st) => { const [cls, ic, label] = STATUS[st]; return h("span", { class: `badge ${cls}` }, icon(ic), label); }; const pnlCls = (v) => (v > 0 ? "pos" : v < 0 ? "neg" : ""); @@ -15,11 +16,11 @@ const planCell = (b) => { const p = b.plan; if (!p) return h("span", { class: "muted small" }, "–"); if (p.action === "SELL") { - return h("span", { class: "badge badge-warning", title: p.flipped ? "La previsione si è girata contro questa posizione" : "Il prezzo ha raggiunto la stima" }, - icon("down"), p.flipped ? "Vendi: stima girata" : "Vendi: obiettivo raggiunto"); + return h("span", { class: "badge badge-warning", title: p.flipped ? t("La previsione si è girata contro questa posizione") : t("Il prezzo ha raggiunto la stima") }, + icon("down"), p.flipped ? t("Vendi: stima girata") : t("Vendi: obiettivo raggiunto")); } - return h("span", { class: "small", title: "Ordine limite di vendita: a quel prezzo incassare rende più che aspettare la risoluzione" }, - "Tieni", p.sell_above != null ? h("span", { class: "muted" }, ` · vendi a ${fmt.cents(p.sell_above)}`) : null); + return h("span", { class: "small", title: t("Ordine limite di vendita: a quel prezzo incassare rende più che aspettare la risoluzione") }, + t("Tieni"), p.sell_above != null ? h("span", { class: "muted" }, t(" · vendi a {0}", fmt.cents(p.sell_above))) : null); }; export async function viewPortfolio(ctx) { @@ -43,12 +44,12 @@ export async function viewPortfolio(ctx) { return btn; }; const excludeMarket = (bet) => { - const btn = h("button", { class: "btn btn-ghost btn-sm", type: "button", title: "Le prossime scommesse automatiche salteranno questo mercato" }, "Escludi mercato"); + const btn = h("button", { class: "btn btn-ghost btn-sm", type: "button", title: t("Le prossime scommesse automatiche salteranno questo mercato") }, t("Escludi mercato")); btn.addEventListener("click", async () => { btn.disabled = true; try { await api("/portfolio/exclusions", { method: "POST", body: { kind: "market", value: bet.market_id, label: bet.question } }); - toast("Mercato escluso dalle scommesse automatiche"); + toast(t("Mercato escluso dalle scommesse automatiche")); refresh(); } catch (e) { toast(e.message, { error: true }); btn.disabled = false; } }); @@ -58,9 +59,9 @@ export async function viewPortfolio(ctx) { const openTable = open.length ? h("div", { class: "table-wrap" }, h("table", {}, h("thead", {}, h("tr", {}, - h("th", {}, "Mercato"), h("th", {}, "Lato"), h("th", { class: "num" }, "Quote"), h("th", { class: "num" }, "Prezzo medio"), - h("th", { class: "num" }, "Costo"), h("th", { class: "num" }, "Prezzo attuale"), h("th", { class: "num" }, "Valore"), - h("th", { class: "num" }, "Profitto latente"), h("th", {}, "Piano"), h("th", {}, "Scade"), admin ? h("th", {}, h("span", { class: "sr-only" }, "Azioni")) : null, + h("th", {}, t("Mercato")), h("th", {}, t("Lato")), h("th", { class: "num" }, t("Quote")), h("th", { class: "num" }, t("Prezzo medio")), + h("th", { class: "num" }, t("Costo")), h("th", { class: "num" }, t("Prezzo attuale")), h("th", { class: "num" }, t("Valore")), + h("th", { class: "num" }, t("Profitto latente")), h("th", {}, t("Piano")), h("th", {}, t("Scade")), admin ? h("th", {}, h("span", { class: "sr-only" }, t("Azioni"))) : null, )), h("tbody", {}, open.map((b) => h("tr", {}, h("td", { class: "q-cell" }, marketLink(b)), h("td", {}, sideBadge(b.side)), @@ -71,66 +72,66 @@ export async function viewPortfolio(ctx) { h("td", { class: "nowrap" }, planCell(b)), h("td", { class: "nowrap" }, fmt.date(b.end_date)), admin ? h("td", { class: "row-actions" }, - betAction(b, `/portfolio/bets/${b.id}/sell`, "Vendi ora", "Scommessa venduta al prezzo del book"), - betAction(b, `/portfolio/bets/${b.id}/exclude`, "Escludi", "Scommessa esclusa dalla simulazione"), excludeMarket(b)) : null, + betAction(b, `/portfolio/bets/${b.id}/sell`, t("Vendi ora"), t("Scommessa venduta al prezzo del book")), + betAction(b, `/portfolio/bets/${b.id}/exclude`, t("Escludi"), t("Scommessa esclusa dalla simulazione")), excludeMarket(b)) : null, ))), - )) : h("p", { class: "secondary" }, "Nessuna posizione aperta."); + )) : h("p", { class: "secondary" }, t("Nessuna posizione aperta.")); const settledTable = settled.length ? h("div", { class: "table-wrap" }, h("table", {}, h("thead", {}, h("tr", {}, - h("th", {}, "Mercato"), h("th", {}, "Lato"), h("th", { class: "num" }, "Costo"), h("th", {}, "Esito"), - h("th", { class: "num" }, "Atteso"), h("th", { class: "num" }, "Risultato"), h("th", {}, "Chiusa"), - admin ? h("th", {}, h("span", { class: "sr-only" }, "Azioni")) : null, + h("th", {}, t("Mercato")), h("th", {}, t("Lato")), h("th", { class: "num" }, t("Costo")), h("th", {}, t("Esito")), + h("th", { class: "num" }, t("Atteso")), h("th", { class: "num" }, t("Risultato")), h("th", {}, t("Chiusa")), + admin ? h("th", {}, h("span", { class: "sr-only" }, t("Azioni"))) : null, )), h("tbody", {}, settled.map((b) => h("tr", {}, h("td", { class: "q-cell" }, marketLink(b)), h("td", {}, sideBadge(b.side)), h("td", { class: "num" }, money(b.outlay)), h("td", {}, statusBadge(b.status), b.status === "sold" - ? h("div", { class: "muted small", title: b.exit_reason || "" }, `a ${fmt.cents(b.exit_price)}${b.exit_reason === "Venduta a mano" ? " · a mano" : ""}`) : null), + ? h("div", { class: "muted small", title: b.exit_reason === "Venduta a mano" ? t("Venduta a mano") : b.exit_reason || "" }, t("a {0}", fmt.cents(b.exit_price)) + (b.exit_reason === "Venduta a mano" ? t(" · a mano") : "")) : null), h("td", { class: `num ${pnlCls(b.expected_profit)}` }, fmt.signedMoney(b.expected_profit)), h("td", { class: `num ${pnlCls(b.pnl)}` }, fmt.signedMoney(b.pnl)), h("td", {}, fmt.date(b.settled_at)), - admin ? h("td", { class: "row-actions" }, betAction(b, `/portfolio/bets/${b.id}/exclude`, "Escludi", "Scommessa esclusa dai risultati")) : null, + admin ? h("td", { class: "row-actions" }, betAction(b, `/portfolio/bets/${b.id}/exclude`, t("Escludi"), t("Scommessa esclusa dai risultati"))) : null, ))), - )) : h("p", { class: "secondary" }, "Nessuna scommessa chiusa: si chiudono quando i mercati si risolvono o quando il piano dice di vendere."); + )) : h("p", { class: "secondary" }, t("Nessuna scommessa chiusa: si chiudono quando i mercati si risolvono o quando il piano dice di vendere.")); const excludedList = excluded.length ? h("details", { class: "card rules" }, - h("summary", {}, `Scommesse escluse dalla simulazione (${excluded.length})`), + h("summary", {}, t("Scommesse escluse dalla simulazione ({0})", excluded.length)), h("ul", { class: "plain-list" }, excluded.map((b) => h("li", {}, h("span", {}, sideBadge(b.side), " ", marketLink(b), h("span", { class: "muted small" }, ` · ${money(b.outlay)} · ${fmt.date(b.created_at)}`)), - admin ? betAction(b, `/portfolio/bets/${b.id}/include`, "Riammetti", "Scommessa riammessa") : null, + admin ? betAction(b, `/portfolio/bets/${b.id}/include`, t("Riammetti"), t("Scommessa riammessa")) : null, ))), ) : null; const diff = data.realized_pnl - data.expected_pnl_settled; const curveCard = h("section", { class: "card", "aria-labelledby": "h-curve" }, - h("div", { class: "card-head" }, h("h2", { id: "h-curve" }, "Andamento del capitale"), - h("span", { class: "muted small" }, `dal ${fmt.date(s.started_at)}`)), + h("div", { class: "card-head" }, h("h2", { id: "h-curve" }, t("Andamento del capitale")), + h("span", { class: "muted small" }, t("dal {0}", fmt.date(s.started_at)))), data.equity_curve.length > 1 ? equityChart(data.equity_curve, s.bankroll) - : h("p", { class: "secondary" }, "La curva compare quando si chiude la prima scommessa."), + : h("p", { class: "secondary" }, t("La curva compare quando si chiude la prima scommessa.")), settled.length ? h("p", { class: "muted small", style: { marginTop: "10px" } }, - `Sulle ${settled.length} scommesse chiuse il modello si aspettava ${fmt.signedMoney(data.expected_pnl_settled)}; il risultato reale è ${fmt.signedMoney(data.realized_pnl)} (${fmt.signedMoney(diff)} di differenza). `, - settled.length < 30 ? "Con meno di 30 scommesse chiuse la differenza dipende molto dal caso." : "") : null, + t("Sulle {0} scommesse chiuse il modello si aspettava {1}; il risultato reale è {2} ({3} di differenza). ", settled.length, fmt.signedMoney(data.expected_pnl_settled), fmt.signedMoney(data.realized_pnl), fmt.signedMoney(diff)), + settled.length < 30 ? t("Con meno di 30 scommesse chiuse la differenza dipende molto dal caso.") : "") : null, ); return h("div", {}, - ctx.pageHead("Portafoglio simulato", - "Ogni segnale che conviene diventa una scommessa virtuale al prezzo reale del momento, venduta quando il piano lo dice o chiusa quando il mercato si risolve. Serve a capire se i segnali fanno guadagnare prima di usare soldi veri.", - h("a", { class: "btn btn-ghost", href: "#/metodo" }, icon("help"), "Come funziona")), + ctx.pageHead(t("Portafoglio simulato"), + t("Ogni segnale che conviene diventa una scommessa virtuale al prezzo reale del momento, venduta quando il piano lo dice o chiusa quando il mercato si risolve. Serve a capire se i segnali fanno guadagnare prima di usare soldi veri."), + h("a", { class: "btn btn-ghost", href: "#/metodo" }, icon("help"), t("Come funziona"))), h("div", { class: "kpis" }, - statTile("Valore attuale", money(data.total_value), data.roi != null ? `${fmt.pts(data.roi).replace(" pt", "%")} da capitale ${money(s.bankroll)}` : ""), - statTile("Profitti realizzati", fmt.signedMoney(data.realized_pnl), `${data.counts.won} vinte · ${data.counts.lost} perse${data.counts.sold ? ` · ${data.counts.sold} vendute` : ""}${data.counts.void ? ` · ${data.counts.void} annullate` : ""}`), - statTile("Profitti latenti", fmt.signedMoney(data.unrealized_pnl), `${data.counts.open} aperte · ${money(data.invested)} investiti`), - statTile("Scommesse vinte", data.hit_rate != null ? fmt.pct(data.hit_rate) : "–", `liquidità ${money(data.cash)}`), - statTile("Prezzo di chiusura", data.clv?.n ? fmt.pts(data.clv.avg) : "–", - data.clv?.n ? `${fmt.pct(data.clv.share_positive)} comprate sotto la chiusura · ${fmt.count(data.clv.n, "mercato chiuso", "mercati chiusi")}` - : data.clv_open?.n ? `finora ${fmt.pts(data.clv_open.avg)} sulle aperte` : "nessun mercato chiuso ancora"), + statTile(t("Valore attuale"), money(data.total_value), data.roi != null ? t("{0} da capitale {1}", fmt.pts(data.roi).replace(` ${t("pt")}`, "%"), money(s.bankroll)) : ""), + statTile(t("Profitti realizzati"), fmt.signedMoney(data.realized_pnl), t("{0} vinte · {1} perse{2}{3}", data.counts.won, data.counts.lost, data.counts.sold ? t(" · {0} vendute", data.counts.sold) : "", data.counts.void ? t(" · {0} annullate", data.counts.void) : "")), + statTile(t("Profitti latenti"), fmt.signedMoney(data.unrealized_pnl), t("{0} aperte · {1} investiti", data.counts.open, money(data.invested))), + statTile(t("Scommesse vinte"), data.hit_rate != null ? fmt.pct(data.hit_rate) : "–", t("liquidità {0}", money(data.cash))), + statTile(t("Prezzo di chiusura"), data.clv?.n ? fmt.pts(data.clv.avg) : "–", + data.clv?.n ? t("{0} comprate sotto la chiusura · {1}", fmt.pct(data.clv.share_positive), fmt.count(data.clv.n, t("mercato chiuso"), t("mercati chiusi"))) + : data.clv_open?.n ? t("finora {0} sulle aperte", fmt.pts(data.clv_open.avg)) : t("nessun mercato chiuso ancora")), ), !bets.length ? h("p", { class: "note", style: { marginBottom: "16px" } }, icon("alert"), - s.auto_paper ? "Ancora nessuna scommessa: la prima arriva con la prossima previsione che supera i controlli economici del preset." - : "Le scommesse automatiche sono disattivate: attivale qui sotto o aggiungile dal dettaglio di un mercato.") : null, + s.auto_paper ? t("Ancora nessuna scommessa: la prima arriva con la prossima previsione che supera i controlli economici del preset.") + : t("Le scommesse automatiche sono disattivate: attivale qui sotto o aggiungile dal dettaglio di un mercato.")) : null, h("div", { class: "grid-2", style: { marginBottom: "16px" } }, curveCard, settingsCard(ctx, data, refresh)), h("div", { class: "stack" }, - h("section", { class: "card", "aria-labelledby": "h-open" }, h("div", { class: "card-head" }, h("h2", { id: "h-open" }, `Posizioni aperte (${open.length})`)), openTable), - h("section", { class: "card", "aria-labelledby": "h-settled" }, h("div", { class: "card-head" }, h("h2", { id: "h-settled" }, `Scommesse chiuse (${settled.length})`)), settledTable), + h("section", { class: "card", "aria-labelledby": "h-open" }, h("div", { class: "card-head" }, h("h2", { id: "h-open" }, t("Posizioni aperte ({0})", open.length))), openTable), + h("section", { class: "card", "aria-labelledby": "h-settled" }, h("div", { class: "card-head" }, h("h2", { id: "h-settled" }, t("Scommesse chiuse ({0})", settled.length))), settledTable), excludedList, exclusionsCard(ctx, exclusions, categories, refresh), ), @@ -140,11 +141,11 @@ export async function viewPortfolio(ctx) { function settingsCard(ctx, data, refresh) { const admin = ctx.isAdmin(); const s = data.settings; - const presetCards = h("div", { class: "presets", role: "radiogroup", "aria-label": "Preset di rischio" }, + const presetCards = h("div", { class: "presets", role: "radiogroup", "aria-label": t("Preset di rischio") }, data.presets.map((p) => { const input = h("input", { type: "radio", name: "preset", value: p.key, id: `preset-${p.key}`, checked: p.key === s.preset, disabled: !admin }); input.addEventListener("change", async () => { - try { await api("/portfolio/settings", { method: "PUT", body: { preset: p.key } }); toast(`Preset ${p.label.toLowerCase()} attivo per le prossime scommesse`); refresh(); } + try { await api("/portfolio/settings", { method: "PUT", body: { preset: p.key } }); toast(t("Preset {0} attivo per le prossime scommesse", p.label.toLowerCase())); refresh(); } catch (e) { toast(e.message, { error: true }); } }); return h("label", { class: "preset", for: `preset-${p.key}` }, input, @@ -152,75 +153,75 @@ function settingsCard(ctx, data, refresh) { h("span", { class: "preset-name" }, p.label), h("span", { class: "preset-desc" }, p.description), h("span", { class: "preset-params mono" }, - `Kelly ×${String(p.kelly_scale).replace(".", ",")} · max ${Math.round(p.max_market_frac * 100)}% per mercato · margine ≥ ${Math.round(p.min_net_edge * 100)} pt · ≤ ${p.max_days} giorni`), + t("Kelly ×{0} · max {1}% per mercato · margine ≥ {2} pt · ≤ {3} giorni", fmt.dec(p.kelly_scale), Math.round(p.max_market_frac * 100), Math.round(p.min_net_edge * 100), p.max_days)), )); })); const auto = h("input", { type: "checkbox", class: "switch", id: "auto-paper", checked: s.auto_paper, disabled: !admin }); auto.addEventListener("change", async () => { - try { await api("/portfolio/settings", { method: "PUT", body: { auto_paper: auto.checked } }); toast(auto.checked ? "Scommesse automatiche attivate" : "Scommesse automatiche disattivate"); } + try { await api("/portfolio/settings", { method: "PUT", body: { auto_paper: auto.checked } }); toast(auto.checked ? t("Scommesse automatiche attivate") : t("Scommesse automatiche disattivate")); } catch (e) { auto.checked = !auto.checked; toast(e.message, { error: true }); } }); const autoSell = h("input", { type: "checkbox", class: "switch", id: "auto-sell", checked: s.auto_sell !== false, disabled: !admin }); autoSell.addEventListener("change", async () => { - try { await api("/portfolio/settings", { method: "PUT", body: { auto_sell: autoSell.checked } }); toast(autoSell.checked ? "Vendite automatiche attivate" : "Vendite automatiche disattivate: le posizioni restano aperte fino alla risoluzione"); } + try { await api("/portfolio/settings", { method: "PUT", body: { auto_sell: autoSell.checked } }); toast(autoSell.checked ? t("Vendite automatiche attivate") : t("Vendite automatiche disattivate: le posizioni restano aperte fino alla risoluzione")); } catch (e) { autoSell.checked = !autoSell.checked; toast(e.message, { error: true }); } }); const resetArea = h("div", {}); const paintReset = () => resetArea.replaceChildren( h("div", { class: "reset-row" }, - h("span", {}, "Capitale iniziale ", h("b", { class: "mono" }, money(s.bankroll))), + h("span", {}, t("Capitale iniziale "), h("b", { class: "mono" }, money(s.bankroll))), admin ? h("button", { class: "btn btn-ghost btn-sm", type: "button", on: { click: askReset } }, "Ricomincia…") : null, )); function askReset() { const amount = h("input", { id: "reset-amount", class: "input", type: "number", min: "10", step: "10", value: String(s.bankroll), style: { maxWidth: "160px" } }); - const go = h("button", { class: "btn btn-danger-solid btn-sm", type: "button" }, "Cancella e ricomincia"); + const go = h("button", { class: "btn btn-danger-solid btn-sm", type: "button" }, t("Cancella e ricomincia")); go.addEventListener("click", async () => { const bankroll = Number(amount.value); - if (!(bankroll >= 10)) return toast("Il capitale deve essere almeno 10 $", { error: true }); + if (!(bankroll >= 10)) return toast(t("Il capitale deve essere almeno 10 $"), { error: true }); go.disabled = true; - try { await api("/portfolio/reset", { method: "POST", body: { bankroll } }); toast("Portafoglio azzerato"); refresh(); } + try { await api("/portfolio/reset", { method: "POST", body: { bankroll } }); toast(t("Portafoglio azzerato")); refresh(); } catch (e) { toast(e.message, { error: true }); go.disabled = false; } }); resetArea.replaceChildren(h("div", { class: "confirm", role: "alert", style: { maxWidth: "none" } }, - h("label", { class: "field", for: "reset-amount" }, "Nuovo capitale ($)", amount), - h("p", { class: "small" }, "Tutte le scommesse simulate verranno cancellate. Le esclusioni restano."), - h("div", { class: "confirm-actions" }, go, h("button", { class: "btn btn-ghost btn-sm", type: "button", on: { click: paintReset } }, "Annulla")), + h("label", { class: "field", for: "reset-amount" }, t("Nuovo capitale ($)"), amount), + h("p", { class: "small" }, t("Tutte le scommesse simulate verranno cancellate. Le esclusioni restano.")), + h("div", { class: "confirm-actions" }, go, h("button", { class: "btn btn-ghost btn-sm", type: "button", on: { click: paintReset } }, t("Annulla"))), )); amount.focus(); } paintReset(); return h("section", { class: "card", "aria-labelledby": "h-psettings" }, - h("h2", { id: "h-psettings", style: { marginBottom: "10px" } }, "Impostazioni della simulazione"), + h("h2", { id: "h-psettings", style: { marginBottom: "10px" } }, t("Impostazioni della simulazione")), presetCards, - h("label", { class: "field", for: "auto-paper", style: { margin: "14px 0 6px" } }, auto, "Aggiungi automaticamente ogni scommessa che conviene"), + h("label", { class: "field", for: "auto-paper", style: { margin: "14px 0 6px" } }, auto, t("Aggiungi automaticamente ogni scommessa che conviene")), h("label", { class: "field", for: "auto-sell", style: { margin: "0 0 6px" } }, autoSell, - "Vendi automaticamente quando il piano lo dice (prezzo arrivato alla stima, o previsione girata)"), + t("Vendi automaticamente quando il piano lo dice (prezzo arrivato alla stima, o previsione girata)")), resetArea, h("p", { class: "muted small", style: { marginTop: "10px" } }, - `Tasso senza rischio ${fmt.pct(data.risk_free_rate)}. `, - data.calibration_factor !== 1 ? `Incertezza corretta ×${data.calibration_factor.toFixed(2).replace(".", ",")} in base ai risultati passati.` - : "L'incertezza verrà corretta con i risultati reali dopo 30 mercati risolti.", - infoTip("Se le previsioni passate sono state peggiori del prezzo di mercato, l'incertezza della stima viene allargata e le puntate si riducono (e viceversa)."), + t("Tasso senza rischio {0}. ", fmt.pct(data.risk_free_rate)), + data.calibration_factor !== 1 ? t("Incertezza corretta ×{0} in base ai risultati passati.", fmt.dec(data.calibration_factor, 2)) + : t("L'incertezza verrà corretta con i risultati reali dopo 30 mercati risolti."), + infoTip(t("Se le previsioni passate sono state peggiori del prezzo di mercato, l'incertezza della stima viene allargata e le puntate si riducono (e viceversa).")), ), ); } function exclusionsCard(ctx, exclusions, categories, refresh) { const admin = ctx.isAdmin(); - const KIND = { market: "Mercato", event: "Evento", category: "Categoria" }; + const KIND = { market: t("Mercato"), event: t("Evento"), category: t("Categoria") }; const list = exclusions.length ? h("ul", { class: "plain-list" }, exclusions.map((x) => h("li", {}, h("span", {}, h("span", { class: "badge badge-outline" }, KIND[x.kind]), " ", x.kind === "category" ? (CATEGORY_LABELS[x.value] || x.value) : x.kind === "market" ? h("a", { href: `#/mercati/${encodeURIComponent(x.value)}` }, x.label || x.value) : (x.label || x.value)), admin ? h("button", { class: "btn btn-ghost btn-sm", type: "button", on: { click: async (e) => { e.currentTarget.disabled = true; - try { await api(`/portfolio/exclusions/${x.id}`, { method: "DELETE" }); toast("Esclusione rimossa"); refresh(); } catch (err) { toast(err.message, { error: true }); } - } } }, "Rimuovi") : null, - ))) : h("p", { class: "secondary" }, "Nessuna esclusione: tutte le scommesse che convengono entrano nella simulazione."); + try { await api(`/portfolio/exclusions/${x.id}`, { method: "DELETE" }); toast(t("Esclusione rimossa")); refresh(); } catch (err) { toast(err.message, { error: true }); } + } } }, t("Rimuovi")) : null, + ))) : h("p", { class: "secondary" }, t("Nessuna esclusione: tutte le scommesse che convengono entrano nella simulazione.")); let adder = null; if (admin) { @@ -228,22 +229,22 @@ function exclusionsCard(ctx, exclusions, categories, refresh) { const options = categories.filter((c) => !taken.has(c)); if (options.length) { const select = h("select", { id: "excl-cat", class: "select" }, options.map((c) => h("option", { value: c }, CATEGORY_LABELS[c] || c))); - const add = h("button", { class: "btn btn-ghost btn-sm", type: "button" }, icon("plus"), "Escludi categoria"); + const add = h("button", { class: "btn btn-ghost btn-sm", type: "button" }, icon("plus"), t("Escludi categoria")); add.addEventListener("click", async () => { add.disabled = true; try { await api("/portfolio/exclusions", { method: "POST", body: { kind: "category", value: select.value, label: CATEGORY_LABELS[select.value] || select.value } }); - toast("Categoria esclusa dalle scommesse automatiche"); refresh(); + toast(t("Categoria esclusa dalle scommesse automatiche")); refresh(); } catch (e) { toast(e.message, { error: true }); add.disabled = false; } }); - adder = h("div", { class: "form-inline", style: { marginTop: "12px" } }, h("label", { class: "field", for: "excl-cat" }, "Categoria", select), add); + adder = h("div", { class: "form-inline", style: { marginTop: "12px" } }, h("label", { class: "field", for: "excl-cat" }, t("Categoria"), select), add); } } return h("section", { class: "card", "aria-labelledby": "h-excl" }, h("div", { class: "card-head" }, h("div", {}, - h("h2", { id: "h-excl" }, "Esclusioni"), - h("p", { class: "muted small" }, "Le scommesse automatiche saltano questi mercati, eventi e categorie. Mercati ed eventi si escludono dal loro dettaglio o dalle posizioni aperte."), + h("h2", { id: "h-excl" }, t("Esclusioni")), + h("p", { class: "muted small" }, t("Le scommesse automatiche saltano questi mercati, eventi e categorie. Mercati ed eventi si escludono dal loro dettaglio o dalle posizioni aperte.")), )), list, adder, ); diff --git a/frontend/views/settings.js b/frontend/views/settings.js index 16d7fe7..e849ea7 100644 --- a/frontend/views/settings.js +++ b/frontend/views/settings.js @@ -1,9 +1,10 @@ // Impostazioni: news sources (list, add, test, edit, enable/disable, delete, catalog) and classification. +import { t } from "../i18n.js"; import { h, clear, api, fmt, toast, icon, externalLink, emptyState, selectField, CATEGORY_LABELS, } from "../ui.js"; -const CATEGORY_OPTIONS = [["", "Nessuna (argomenti misti)"], ...Object.entries(CATEGORY_LABELS)]; +const CATEGORY_OPTIONS = [["", t("Nessuna (argomenti misti)")], ...Object.entries(CATEGORY_LABELS)]; export async function viewSettings(ctx) { const admin = ctx.isAdmin(); @@ -23,26 +24,26 @@ export async function viewSettings(ctx) { const sourcesCard = h("section", { class: "card", "aria-labelledby": "h-sources" }, h("div", { class: "card-head" }, h("div", {}, - h("h2", { id: "h-sources" }, "Fonti"), - h("p", { class: "muted small" }, `${active} attive su ${sources.length}`, errors ? ` · ${errors} con errori` : ""), + h("h2", { id: "h-sources" }, t("Fonti")), + h("p", { class: "muted small" }, t("{0} attive su {1}", active, sources.length), errors ? t(" · {0} con errori", errors) : ""), ), admin ? h("div", { class: "actions" }, - h("button", { class: "btn btn-primary", type: "button", on: { click: () => openForm() } }, icon("plus"), "Aggiungi fonte"), + h("button", { class: "btn btn-primary", type: "button", on: { click: () => openForm() } }, icon("plus"), t("Aggiungi fonte")), ) : null, ), sources.length ? h("div", { class: "table-wrap" }, h("table", { class: "sources-table" }, h("thead", {}, h("tr", {}, - h("th", {}, "Fonte"), h("th", {}, "Argomento"), h("th", {}, "Ultimo aggiornamento"), - h("th", { class: "num" }, "Notizie"), h("th", {}, "Attiva"), admin ? h("th", {}, h("span", { class: "sr-only" }, "Azioni")) : null, + h("th", {}, t("Fonte")), h("th", {}, t("Argomento")), h("th", {}, t("Ultimo aggiornamento")), + h("th", { class: "num" }, t("Notizie")), h("th", {}, t("Attiva")), admin ? h("th", {}, h("span", { class: "sr-only" }, t("Azioni"))) : null, )), h("tbody", {}, sources.map((s) => sourceRow(ctx, s, { admin, onEdit: openForm, onChange: rerender }))), )) - : emptyState("Nessuna fonte", admin ? "Aggiungi una fonte o scegline alcune tra quelle consigliate qui sotto." : "Un amministratore deve aggiungere le fonti."), + : emptyState(t("Nessuna fonte"), admin ? t("Aggiungi una fonte o scegline alcune tra quelle consigliate qui sotto.") : t("Un amministratore deve aggiungere le fonti.")), ); return h("div", {}, - ctx.pageHead("Impostazioni", admin ? "Fonti delle notizie e classificazione." : "Fonti delle notizie e parametri. Solo gli amministratori possono modificarli."), + ctx.pageHead(t("Impostazioni"), admin ? t("Fonti delle notizie e classificazione.") : t("Fonti delle notizie e parametri. Solo gli amministratori possono modificarli.")), h("div", { class: "stack" }, formHolder, sourcesCard, @@ -59,28 +60,28 @@ export async function viewSettings(ctx) { function statusCell(s) { if (!s.last_fetched_at) { - return h("span", { class: "muted small" }, s.active ? "In attesa del primo aggiornamento" : "Mai scaricata"); + return h("span", { class: "muted small" }, s.active ? t("In attesa del primo aggiornamento") : t("Mai scaricata")); } if (s.last_status === "error") { return h("div", { class: "status-cell" }, - h("span", { class: "badge badge-critical" }, icon("x"), "Errore"), - h("span", { class: "small error-text", title: s.last_error || "" }, s.last_error || "Errore sconosciuto"), + h("span", { class: "badge badge-critical" }, icon("x"), t("Errore")), + h("span", { class: "small error-text", title: s.last_error || "" }, s.last_error || t("Errore sconosciuto")), h("span", { class: "muted small" }, fmt.ago(s.last_fetched_at)), ); } return h("div", { class: "status-cell" }, - h("span", { class: "badge badge-good" }, icon("check"), s.last_new_items ? `${s.last_new_items} nuove` : "Nessuna novità"), + h("span", { class: "badge badge-good" }, icon("check"), s.last_new_items ? t("{0} nuove", s.last_new_items) : t("Nessuna novità")), h("span", { class: "muted small", title: fmt.dateTime(s.last_fetched_at) }, fmt.ago(s.last_fetched_at)), ); } function sourceRow(ctx, s, { admin, onEdit, onChange }) { - const toggle = h("input", { type: "checkbox", class: "switch", checked: s.active, disabled: !admin, "aria-label": `${s.active ? "Disattiva" : "Attiva"} ${s.name}` }); + const toggle = h("input", { type: "checkbox", class: "switch", checked: s.active, disabled: !admin, "aria-label": `${s.active ? t("Disattiva") : t("Attiva")} ${s.name}` }); toggle.addEventListener("change", async () => { toggle.disabled = true; try { await api(`/sources/${s.id}`, { method: "PATCH", body: { active: toggle.checked } }); - toast(toggle.checked ? `${s.name} attivata` : `${s.name} disattivata: le sue notizie restano nell'archivio`); + toast(toggle.checked ? t("{0} attivata", s.name) : t("{0} disattivata: le sue notizie restano nell'archivio", s.name)); onChange(); } catch (e) { toggle.checked = !toggle.checked; @@ -91,19 +92,19 @@ function sourceRow(ctx, s, { admin, onEdit, onChange }) { const actions = h("td", { class: "row-actions" }); const paintActions = () => actions.replaceChildren( - h("button", { class: "btn btn-ghost btn-sm btn-square", type: "button", title: "Scarica subito le notizie di questa fonte", disabled: !s.active, - on: { click: (e) => fetchNow(e.currentTarget, s, onChange) } }, icon("refresh"), h("span", { class: "sr-only" }, `Aggiorna ${s.name}`)), - h("button", { class: "btn btn-ghost btn-sm", type: "button", on: { click: () => onEdit(s) } }, "Modifica"), - h("button", { class: "btn btn-ghost btn-sm btn-danger", type: "button", on: { click: confirmDelete } }, "Elimina"), + h("button", { class: "btn btn-ghost btn-sm btn-square", type: "button", title: t("Scarica subito le notizie di questa fonte"), disabled: !s.active, + on: { click: (e) => fetchNow(e.currentTarget, s, onChange) } }, icon("refresh"), h("span", { class: "sr-only" }, t("Aggiorna {0}", s.name))), + h("button", { class: "btn btn-ghost btn-sm", type: "button", on: { click: () => onEdit(s) } }, t("Modifica")), + h("button", { class: "btn btn-ghost btn-sm btn-danger", type: "button", on: { click: confirmDelete } }, t("Elimina")), ); function confirmDelete() { - const confirmBtn = h("button", { class: "btn btn-sm btn-danger-solid", type: "button" }, h("span", { class: "spinner", "aria-hidden": "true" }), "Elimina"); + const confirmBtn = h("button", { class: "btn btn-sm btn-danger-solid", type: "button" }, h("span", { class: "spinner", "aria-hidden": "true" }), t("Elimina")); confirmBtn.addEventListener("click", async () => { confirmBtn.disabled = true; confirmBtn.classList.add("loading"); try { await api(`/sources/${s.id}`, { method: "DELETE", params: { delete_articles: s.article_count > 0 || null } }); - toast(`${s.name} eliminata${s.article_count ? ` con ${fmt.count(s.article_count, "notizia", "notizie")}` : ""}`); + toast(s.article_count ? t("{0} eliminata con {1}", s.name, fmt.count(s.article_count, t("notizia"), t("notizie"))) : t("{0} eliminata", s.name)); onChange(); } catch (e) { toast(e.message, { error: true }); @@ -112,11 +113,11 @@ function sourceRow(ctx, s, { admin, onEdit, onChange }) { }); actions.replaceChildren(h("div", { class: "confirm", role: "alert" }, h("span", { class: "small" }, s.article_count - ? `Eliminare la fonte e ${s.article_count === 1 ? "la sua notizia" : `le sue ${fmt.int(s.article_count)} notizie`}? Per tenerle, disattivala.` - : "Eliminare la fonte?"), + ? t("Eliminare la fonte e {0}? Per tenerle, disattivala.", s.article_count === 1 ? t("la sua notizia") : t("le sue {0} notizie", fmt.int(s.article_count))) + : t("Eliminare la fonte?")), h("div", { class: "confirm-actions" }, confirmBtn, - h("button", { class: "btn btn-ghost btn-sm", type: "button", on: { click: paintActions } }, "Annulla"), + h("button", { class: "btn btn-ghost btn-sm", type: "button", on: { click: paintActions } }, t("Annulla")), ), )); confirmBtn.focus(); @@ -128,7 +129,7 @@ function sourceRow(ctx, s, { admin, onEdit, onChange }) { h("div", { class: "source-name" }, s.name), externalLink(s.url, h("span", { class: "source-url" }, s.url.replace(/^https?:\/\//, ""))), ), - h("td", {}, s.category_hint ? h("span", { class: "badge" }, CATEGORY_LABELS[s.category_hint] || s.category_hint) : h("span", { class: "muted small" }, "Misti")), + h("td", {}, s.category_hint ? h("span", { class: "badge" }, CATEGORY_LABELS[s.category_hint] || s.category_hint) : h("span", { class: "muted small" }, t("Misti"))), h("td", {}, statusCell(s)), h("td", { class: "num" }, fmt.int(s.article_count)), h("td", {}, toggle), @@ -140,7 +141,7 @@ async function fetchNow(btn, s, onChange) { btn.disabled = true; try { await api(`/sources/${s.id}/fetch`, { method: "POST" }); - toast(`Aggiornamento di ${s.name} avviato`); + toast(t("Aggiornamento di {0} avviato", s.name)); setTimeout(onChange, 5000); } catch (e) { toast(e.message, { error: true }); @@ -157,35 +158,35 @@ function sourceForm(ctx, source, onClose, onSaved) { const url = h("input", { id: "s-url", class: "input", type: "url", required: true, maxlength: "2000", value: source?.url || "", placeholder: "https://esempio.com/feed.xml", autocomplete: "off", "aria-describedby": "s-url-msg" }); const name = h("input", { id: "s-name", class: "input", required: true, maxlength: "80", value: source?.name || "", autocomplete: "off", "aria-describedby": "s-name-msg" }); - const category = selectField("s-category", "Argomento prevalente", CATEGORY_OPTIONS, source?.category_hint || "", () => {}); - const active = ctx.checkField("s-active", "Attiva (scarica le notizie a ogni aggiornamento)", source ? source.active : true, () => {}); + const category = selectField("s-category", t("Argomento prevalente"), CATEGORY_OPTIONS, source?.category_hint || "", () => {}); + const active = ctx.checkField("s-active", t("Attiva (scarica le notizie a ogni aggiornamento)"), source ? source.active : true, () => {}); const result = h("div", { class: "feed-test", "aria-live": "polite" }); const setMsg = (id, text) => { const msg = document.getElementById(`${id}-msg`); const input = document.getElementById(id); - msg.textContent = text || (id === "s-url" ? "Indirizzo del feed RSS o Atom, non della pagina web." : ""); + msg.textContent = text || (id === "s-url" ? t("Indirizzo del feed RSS o Atom, non della pagina web.") : ""); msg.classList.toggle("field-error", Boolean(text)); input.setAttribute("aria-invalid", text ? "true" : "false"); }; - const testBtn = h("button", { class: "btn btn-ghost", type: "button" }, h("span", { class: "spinner", "aria-hidden": "true" }), icon("eye"), "Prova il feed"); + const testBtn = h("button", { class: "btn btn-ghost", type: "button" }, h("span", { class: "spinner", "aria-hidden": "true" }), icon("eye"), t("Prova il feed")); testBtn.addEventListener("click", async () => { - if (!url.value.trim()) { setMsg("s-url", "Inserisci l'indirizzo del feed."); url.focus(); return; } + if (!url.value.trim()) { setMsg("s-url", t("Inserisci l'indirizzo del feed.")); url.focus(); return; } setMsg("s-url", ""); testBtn.disabled = true; testBtn.classList.add("loading"); - result.replaceChildren(h("p", { class: "muted small" }, "Scarico il feed…")); + result.replaceChildren(h("p", { class: "muted small" }, t("Scarico il feed…"))); try { const r = await api("/sources/test", { method: "POST", body: { url: url.value.trim() } }); if (r.ok) { if (!name.value.trim() && r.title) name.value = r.title.slice(0, 80); result.replaceChildren(h("div", { class: "test-ok" }, - h("p", {}, h("span", { class: "badge badge-good" }, icon("check"), "Feed valido"), " ", - r.title ? h("b", {}, r.title) : null, ` · ${fmt.count(r.item_count, "notizia", "notizie")}`), - h("ul", { class: "samples" }, r.samples.map((t) => h("li", {}, t))), + h("p", {}, h("span", { class: "badge badge-good" }, icon("check"), t("Feed valido")), " ", + r.title ? h("b", {}, r.title) : null, ` · ${fmt.count(r.item_count, t("notizia"), t("notizie"))}`), + h("ul", { class: "samples" }, r.samples.map((x) => h("li", {}, x))), )); } else { - result.replaceChildren(h("p", { class: "test-ko" }, h("span", { class: "badge badge-critical" }, icon("x"), "Non funziona"), " ", r.error)); + result.replaceChildren(h("p", { class: "test-ko" }, h("span", { class: "badge badge-critical" }, icon("x"), t("Non funziona")), " ", r.error)); } } catch (e) { result.replaceChildren(h("p", { class: "test-ko" }, e.message)); @@ -195,19 +196,19 @@ function sourceForm(ctx, source, onClose, onSaved) { } }); - const save = h("button", { class: "btn btn-primary", type: "submit" }, h("span", { class: "spinner", "aria-hidden": "true" }), editing ? "Salva modifiche" : "Aggiungi fonte"); + const save = h("button", { class: "btn btn-primary", type: "submit" }, h("span", { class: "spinner", "aria-hidden": "true" }), editing ? t("Salva modifiche") : t("Aggiungi fonte")); const form = h("form", { class: "form", novalidate: true }, - field("s-url", "Indirizzo del feed", h("div", { class: "input-row" }, url, testBtn), "Indirizzo del feed RSS o Atom, non della pagina web."), + field("s-url", t("Indirizzo del feed"), h("div", { class: "input-row" }, url, testBtn), t("Indirizzo del feed RSS o Atom, non della pagina web.")), result, - field("s-name", "Nome", name, ""), + field("s-name", t("Nome"), name, ""), h("div", { class: "form-inline" }, category, active), - h("div", { class: "form-actions" }, save, h("button", { class: "btn btn-ghost", type: "button", on: { click: onClose } }, "Annulla")), + h("div", { class: "form-actions" }, save, h("button", { class: "btn btn-ghost", type: "button", on: { click: onClose } }, t("Annulla"))), ); form.addEventListener("submit", async (e) => { e.preventDefault(); setMsg("s-url", ""); setMsg("s-name", ""); - if (!url.value.trim()) { setMsg("s-url", "Inserisci l'indirizzo del feed."); return url.focus(); } - if (name.value.trim().length < 2) { setMsg("s-name", "Dai un nome alla fonte (almeno 2 caratteri)."); return name.focus(); } + if (!url.value.trim()) { setMsg("s-url", t("Inserisci l'indirizzo del feed.")); return url.focus(); } + if (name.value.trim().length < 2) { setMsg("s-name", t("Dai un nome alla fonte (almeno 2 caratteri).")); return name.focus(); } save.disabled = true; save.classList.add("loading"); const body = { @@ -218,7 +219,7 @@ function sourceForm(ctx, source, onClose, onSaved) { try { if (editing) await api(`/sources/${source.id}`, { method: "PATCH", body }); else await api("/sources", { method: "POST", body }); - toast(editing ? `${body.name} aggiornata` : `${body.name} aggiunta. Le notizie arrivano al prossimo aggiornamento.`); + toast(editing ? t("{0} aggiornata", body.name) : t("{0} aggiunta. Le notizie arrivano al prossimo aggiornamento.", body.name)); onSaved(); } catch (err) { if (err.status === 409 || /indirizzo|http/i.test(err.message)) { setMsg("s-url", err.message); url.focus(); } @@ -230,7 +231,7 @@ function sourceForm(ctx, source, onClose, onSaved) { }); return h("section", { class: "card form-card", "aria-labelledby": "h-source-form" }, - h("div", { class: "card-head" }, h("h2", { id: "h-source-form" }, editing ? `Modifica ${source.name}` : "Nuova fonte")), + h("div", { class: "card-head" }, h("h2", { id: "h-source-form" }, editing ? t("Modifica {0}", source.name) : t("Nuova fonte"))), form, ); } @@ -240,17 +241,17 @@ function sourceForm(ctx, source, onClose, onSaved) { function catalogCard(ctx, catalog, onAdded) { const available = catalog.filter((c) => !c.added); const selected = new Set(); - const addBtn = h("button", { class: "btn btn-primary", type: "button", disabled: true }, h("span", { class: "spinner", "aria-hidden": "true" }), "Aggiungi selezionate"); + const addBtn = h("button", { class: "btn btn-primary", type: "button", disabled: true }, h("span", { class: "spinner", "aria-hidden": "true" }), t("Aggiungi selezionate")); const paintBtn = () => { addBtn.disabled = selected.size === 0; - addBtn.lastChild.textContent = selected.size ? `Aggiungi selezionate (${selected.size})` : "Aggiungi selezionate"; + addBtn.lastChild.textContent = selected.size ? t("Aggiungi selezionate ({0})", selected.size) : t("Aggiungi selezionate"); }; addBtn.addEventListener("click", async () => { addBtn.disabled = true; addBtn.classList.add("loading"); try { const added = await api("/sources/catalog", { method: "POST", body: { urls: [...selected] } }); - toast(`${added.length === 1 ? "1 fonte aggiunta" : `${added.length} fonti aggiunte`}. Le notizie arrivano al prossimo aggiornamento.`); + toast(`${added.length === 1 ? t("1 fonte aggiunta") : t("{0} fonti aggiunte", added.length)}. ${t("Le notizie arrivano al prossimo aggiornamento.")}`); onAdded(); } catch (e) { toast(e.message, { error: true }); @@ -284,18 +285,18 @@ function catalogCard(ctx, catalog, onAdded) { return h("section", { class: "card", "aria-labelledby": "h-catalog" }, h("div", { class: "card-head" }, h("div", {}, - h("h2", { id: "h-catalog" }, "Fonti consigliate"), + h("h2", { id: "h-catalog" }, t("Fonti consigliate")), h("p", { class: "muted small" }, available.length - ? `${available.length} fonti selezionate per i temi più scambiati su Polymarket, non ancora aggiunte.` - : "Hai già aggiunto tutte le fonti consigliate."), + ? t("{0} fonti selezionate per i temi più scambiati su Polymarket, non ancora aggiunte.", available.length) + : t("Hai già aggiunto tutte le fonti consigliate.")), ), available.length ? h("div", { class: "actions" }, - h("button", { class: "btn btn-ghost", type: "button", on: { click: () => toggleAll(catalog) } }, "Seleziona tutte"), + h("button", { class: "btn btn-ghost", type: "button", on: { click: () => toggleAll(catalog) } }, t("Seleziona tutte")), addBtn) : null, ), h("div", { class: "catalog" }, [...groups.entries()].map(([cat, items]) => h("fieldset", { class: "catalog-group" }, - h("legend", {}, cat ? CATEGORY_LABELS[cat] || cat : "Generaliste", + h("legend", {}, cat ? CATEGORY_LABELS[cat] || cat : t("Generaliste"), items.some((i) => !i.added) ? h("button", { class: "btn-link small", type: "button", on: { click: () => toggleAll(items) } }, "tutte") : null), @@ -307,7 +308,7 @@ function catalogCard(ctx, catalog, onAdded) { return h("label", { class: `catalog-item${item.added ? " added" : ""}`, for: id }, box, h("span", {}, - h("span", { class: "catalog-name" }, item.name, item.added ? h("span", { class: "muted small" }, " · già aggiunta") : null), + h("span", { class: "catalog-name" }, item.name, item.added ? h("span", { class: "muted small" }, t(" · già aggiunta")) : null), item.description ? h("span", { class: "muted small catalog-desc" }, item.description) : null, ), ); @@ -321,13 +322,13 @@ function catalogCard(ctx, catalog, onAdded) { function classificationCard(ctx) { const st = ctx.status || {}; - const btn = h("button", { class: "btn btn-ghost", type: "button" }, h("span", { class: "spinner", "aria-hidden": "true" }), icon("refresh"), "Riclassifica le notizie"); + const btn = h("button", { class: "btn btn-ghost", type: "button" }, h("span", { class: "spinner", "aria-hidden": "true" }), icon("refresh"), t("Riclassifica le notizie")); btn.addEventListener("click", async () => { btn.disabled = true; btn.classList.add("loading"); try { await api("/ingest/reclassify", { method: "POST", params: { limit: 500 } }); - toast("Riclassificazione avviata: fino a 500 notizie, dalle più recenti."); + toast(t("Riclassificazione avviata: fino a 500 notizie, dalle più recenti.")); } catch (e) { toast(e.message, { error: true }); } finally { @@ -336,13 +337,13 @@ function classificationCard(ctx) { } }); return h("section", { class: "card", "aria-labelledby": "h-classif" }, - h("h2", { id: "h-classif" }, "Classificazione"), + h("h2", { id: "h-classif" }, t("Classificazione")), h("p", { class: "secondary", style: { margin: "8px 0" } }, st.jev_enabled - ? "Le notizie sono classificate da Jev: categoria, regione, opinione o notizia, rilevanza per i mercati, autorevolezza e clickbait." - : "Senza chiave TypeSafe le notizie sono classificate con parole chiave. Aggiungi TYPESAFE_API_KEY per usare Jev."), + ? t("Le notizie sono classificate da Jev: categoria, regione, opinione o notizia, rilevanza per i mercati, autorevolezza e clickbait.") + : t("Senza chiave TypeSafe le notizie sono classificate con parole chiave. Aggiungi TYPESAFE_API_KEY per usare Jev.")), h("p", { class: "muted small" }, st.jev_enabled - ? "La riclassificazione passa a Jev le notizie classificate prima con le parole chiave. Ogni notizia è una chiamata a pagamento." - : "La riclassificazione applica le regole attuali alle notizie salvate prima dell'ultimo aggiornamento."), + ? t("La riclassificazione passa a Jev le notizie classificate prima con le parole chiave. Ogni notizia è una chiamata a pagamento.") + : t("La riclassificazione applica le regole attuali alle notizie salvate prima dell'ultimo aggiornamento.")), ctx.isAdmin() ? h("div", { style: { marginTop: "12px" } }, btn) : null, ); } @@ -351,19 +352,19 @@ function parametersCard(ctx) { const st = ctx.status || {}; const row = (label, value, env) => [h("dt", {}, label), h("dd", {}, h("b", { class: "mono" }, value), h("code", { class: "env" }, env))]; return h("section", { class: "card", "aria-labelledby": "h-params" }, - h("h2", { id: "h-params" }, "Parametri di previsione"), - h("p", { class: "muted small", style: { margin: "6px 0 10px" } }, "Si cambiano nel file .env del server e valgono al riavvio. ", h("a", { href: "#/metodo" }, "Cosa significano")), + h("h2", { id: "h-params" }, t("Parametri di previsione")), + h("p", { class: "muted small", style: { margin: "6px 0 10px" } }, t("Si cambiano nel file .env del server e valgono al riavvio. "), h("a", { href: "#/metodo" }, t("Cosa significano"))), h("dl", { class: "dl params" }, - row("Pertinenza minima notizia–mercato", fmt.pct(st.market_match_threshold), "MARKET_MATCH_THRESHOLD"), - row("Finestra delle notizie", `${st.market_news_window_hours ?? "–"} ore`, "MARKET_NEWS_WINDOW_HOURS"), - row("Peso massimo di Jev", fmt.pct(st.model_weight_max), "MODEL_WEIGHT_MAX"), - row("Come si uniscono Jev e prezzo", st.blend_method === "linear" ? "media lineare" : "media in log-odds", "BLEND_METHOD"), - row("Calibrazione di Jev (a, b)", `${fmt.num3(st.jev_calib_a ?? 0)} · ${fmt.num3(st.jev_calib_b ?? 1)}`, "JEV_CALIB_A / JEV_CALIB_B"), - row("Chiamate a Jev per previsione", String(st.jev_samples ?? 1), "JEV_SAMPLES"), - row("Edge minimo", st.min_edge != null ? `${Math.round(st.min_edge * 100)} pt` : "–", "MIN_EDGE"), - row("Evidenze minime", fmt.pct(st.min_evidence), "MIN_EVIDENCE"), - row("Frazione di Kelly", st.kelly_fraction != null ? String(st.kelly_fraction).replace(".", ",") : "–", "KELLY_FRACTION"), - row("Previsioni automatiche", st.prediction_auto ? "Sì" : "No", "PREDICTION_AUTO"), + row(t("Pertinenza minima notizia–mercato"), fmt.pct(st.market_match_threshold), "MARKET_MATCH_THRESHOLD"), + row(t("Finestra delle notizie"), t("{0} ore", st.market_news_window_hours ?? "–"), "MARKET_NEWS_WINDOW_HOURS"), + row(t("Peso massimo di Jev"), fmt.pct(st.model_weight_max), "MODEL_WEIGHT_MAX"), + row(t("Come si uniscono Jev e prezzo"), st.blend_method === "linear" ? t("media lineare") : t("media in log-odds"), "BLEND_METHOD"), + row(t("Calibrazione di Jev (a, b)"), `${fmt.num3(st.jev_calib_a ?? 0)} · ${fmt.num3(st.jev_calib_b ?? 1)}`, "JEV_CALIB_A / JEV_CALIB_B"), + row(t("Chiamate a Jev per previsione"), String(st.jev_samples ?? 1), "JEV_SAMPLES"), + row(t("Edge minimo"), st.min_edge != null ? `${Math.round(st.min_edge * 100)} ${t("pt")}` : "–", "MIN_EDGE"), + row(t("Evidenze minime"), fmt.pct(st.min_evidence), "MIN_EVIDENCE"), + row(t("Frazione di Kelly"), st.kelly_fraction != null ? fmt.dec(st.kelly_fraction) : "–", "KELLY_FRACTION"), + row(t("Previsioni automatiche"), st.prediction_auto ? t("Sì") : t("No"), "PREDICTION_AUTO"), ), ); } diff --git a/frontend/views/usage.js b/frontend/views/usage.js index fb4e57a..8dfa3b1 100644 --- a/frontend/views/usage.js +++ b/frontend/views/usage.js @@ -1,11 +1,12 @@ // Uso e costi: paid AI calls per day and per feature, daily limits and prices. +import { t } from "../i18n.js"; import { h, api, fmt, toast, icon, statTile, meter, infoTip } from "../ui.js"; import { dailyBars } from "../charts.js"; -const PROVIDERS = { jev: "TypeSafe Jev", groq: "Groq", gemini: "Gemini" }; +const PROVIDERS = { jev: t("TypeSafe Jev"), groq: t("Groq"), gemini: t("Gemini") }; const state = { metric: "cost" }; -const usd = (v) => (v == null ? "–" : v < 0.01 && v > 0 ? `${v.toFixed(4).replace(".", ",")} $` : fmt.money(v)); -const tokens = (n) => (n >= 1e6 ? `${(n / 1e6).toFixed(1).replace(".", ",")} M` : n >= 1e3 ? `${Math.round(n / 1e3)} k` : fmt.int(n)); +const usd = (v) => (v == null ? "–" : v < 0.01 && v > 0 ? fmt.dollars(fmt.dec(v, 4)) : fmt.money(v)); +const tokens = (n) => (n >= 1e6 ? `${fmt.dec(n / 1e6, 1)} M` : n >= 1e3 ? `${Math.round(n / 1e3)} k` : fmt.int(n)); export async function viewUsage(ctx) { const data = await api("/usage", { params: { days: 30 } }); @@ -15,8 +16,8 @@ export async function viewUsage(ctx) { .some((k) => settings[k].value > 0); return h("div", {}, - ctx.pageHead("Uso e costi", - "Chiamate alle API a pagamento (Jev, Groq, Gemini) per giorno e per funzione, con limiti giornalieri. Oltre il limite le chiamate si fermano fino a mezzanotte: classificazione e riassunti passano alle alternative gratuite."), + ctx.pageHead(t("Uso e costi"), + t("Chiamate alle API a pagamento (Jev, Groq, Gemini) per giorno e per funzione, con limiti giornalieri. Oltre il limite le chiamate si fermano fino a mezzanotte: classificazione e riassunti passano alle alternative gratuite.")), todayCard(today, pricesSet), historyCard(report, items), breakdownCard(report, items), @@ -24,20 +25,20 @@ export async function viewUsage(ctx) { ); } -function todayCard(t, pricesSet) { - const resetIn = `${Math.floor(t.resets_in_seconds / 3600)} h ${Math.floor((t.resets_in_seconds % 3600) / 60)} min`; - const jevTile = statTile("Chiamate a Jev oggi", fmt.int(t.jev_calls), - t.jev_call_limit ? `su ${fmt.int(t.jev_call_limit)} al giorno` : "nessun limite impostato"); - const costTile = statTile("Spesa stimata oggi", pricesSet ? usd(t.cost) : "–", - !pricesSet ? "imposta i prezzi qui sotto" : t.budget ? `su ${usd(t.budget)} al giorno` : "nessun budget impostato"); - if (t.share_jev_calls != null) jevTile.append(meter(Math.min(1, t.share_jev_calls), `${fmt.pct(t.share_jev_calls)} del limite`)); - if (t.share_cost != null) costTile.append(meter(Math.min(1, t.share_cost), `${fmt.pct(t.share_cost)} del budget`)); - const warn = [t.share_jev_calls, t.share_cost].some((v) => v != null && v >= t.warn_share); +function todayCard(today, pricesSet) { + const resetIn = t("{0} h {1} min", Math.floor(today.resets_in_seconds / 3600), Math.floor((today.resets_in_seconds % 3600) / 60)); + const jevTile = statTile(t("Chiamate a Jev oggi"), fmt.int(today.jev_calls), + today.jev_call_limit ? t("su {0} al giorno", fmt.int(today.jev_call_limit)) : t("nessun limite impostato")); + const costTile = statTile(t("Spesa stimata oggi"), pricesSet ? usd(today.cost) : "–", + !pricesSet ? t("imposta i prezzi qui sotto") : today.budget ? t("su {0} al giorno", usd(today.budget)) : t("nessun budget impostato")); + if (today.share_jev_calls != null) jevTile.append(meter(Math.min(1, today.share_jev_calls), t("{0} del limite", fmt.pct(today.share_jev_calls)))); + if (today.share_cost != null) costTile.append(meter(Math.min(1, today.share_cost), t("{0} del budget", fmt.pct(today.share_cost)))); + const warn = [today.share_jev_calls, today.share_cost].some((v) => v != null && v >= today.warn_share); return h("section", { class: "card", "aria-labelledby": "h-us-today", style: { marginBottom: "16px" } }, - h("div", { class: "card-head" }, h("h2", { id: "h-us-today" }, "Oggi"), h("span", { class: "muted small" }, `si azzera tra ${resetIn}`)), - t.blocked ? h("div", { class: "notice", role: "alert" }, icon("alert"), h("p", {}, - h("b", {}, "Limite giornaliero raggiunto. "), "Previsioni, allerte e backtest sono in pausa fino a mezzanotte; classificazione e riassunti usano le alternative gratuite.")) - : warn ? h("div", { class: "notice", role: "status" }, icon("alert"), h("p", {}, `Hai usato più del ${fmt.pct(t.warn_share)} di un limite giornaliero.`)) : null, + h("div", { class: "card-head" }, h("h2", { id: "h-us-today" }, t("Oggi")), h("span", { class: "muted small" }, t("si azzera tra {0}", resetIn))), + today.blocked ? h("div", { class: "notice", role: "alert" }, icon("alert"), h("p", {}, + h("b", {}, t("Limite giornaliero raggiunto. ")), t("Previsioni, allerte e backtest sono in pausa fino a mezzanotte; classificazione e riassunti usano le alternative gratuite."))) + : warn ? h("div", { class: "notice", role: "status" }, icon("alert"), h("p", {}, t("Hai usato più del {0} di un limite giornaliero.", fmt.pct(today.warn_share)))) : null, h("div", { class: "kpis" }, jevTile, costTile), ); } @@ -64,11 +65,11 @@ function historyCard(report, items) { return { day: d, value: list.reduce((a, it) => a + valueOf(it), 0), tip }; }); holder.replaceChildren(rows.some((r) => r.value > 0) - ? dailyBars(rows, { format, label: `Uso per giorno negli ultimi ${report.days} giorni` }) - : h("p", { class: "secondary" }, "Nessuna chiamata a pagamento in questo periodo.")); + ? dailyBars(rows, { format, label: t("Uso per giorno negli ultimi {0} giorni", report.days) }) + : h("p", { class: "secondary" }, t("Nessuna chiamata a pagamento in questo periodo."))); }; - const seg = h("div", { class: "segmented", role: "group", "aria-label": "Misura" }, - [["cost", "Costo"], ["calls", "Chiamate"], ["tokens", "Token"]].map(([k, l]) => { + const seg = h("div", { class: "segmented", role: "group", "aria-label": t("Misura") }, + [["cost", t("Costo")], ["calls", t("Chiamate")], ["tokens", t("Token")]].map(([k, l]) => { const b = h("button", { class: "seg", type: "button", "aria-pressed": String(state.metric === k) }, l); b.addEventListener("click", () => { state.metric = k; @@ -79,7 +80,7 @@ function historyCard(report, items) { })); paint(); return h("section", { class: "card", "aria-labelledby": "h-us-hist", style: { marginBottom: "16px" } }, - h("div", { class: "card-head" }, h("h2", { id: "h-us-hist" }, `Ultimi ${report.days} giorni`), seg), + h("div", { class: "card-head" }, h("h2", { id: "h-us-hist" }, t("Ultimi {0} giorni", report.days)), seg), holder); } @@ -95,16 +96,16 @@ function breakdownCard(report, items) { }; const totalCost = items.reduce((a, it) => a + it.cost, 0); const table = (title, rows, names) => h("div", { class: "table-wrap" }, h("table", { class: "compact-table" }, - h("thead", {}, h("tr", {}, ...[title, "Chiamate", "Errori", "Token in", "Token out", "Costo", "Quota"].map((t, i) => h("th", { scope: "col", class: i ? "num" : null }, t)))), + h("thead", {}, h("tr", {}, ...[title, t("Chiamate"), t("Errori"), t("Token in"), t("Token out"), t("Costo"), t("Quota")].map((t, i) => h("th", { scope: "col", class: i ? "num" : null }, t)))), h("tbody", {}, rows.map(([k, v]) => h("tr", {}, h("th", { scope: "row" }, names[k] || k), h("td", { class: "num" }, fmt.int(v.calls)), h("td", { class: "num" }, fmt.int(v.errors)), h("td", { class: "num" }, tokens(v.input)), h("td", { class: "num" }, tokens(v.output)), h("td", { class: "num" }, usd(v.cost)), h("td", { class: "num" }, totalCost ? fmt.pct(v.cost / totalCost) : "–")))))); return h("section", { class: "card", "aria-labelledby": "h-us-break", style: { marginBottom: "16px" } }, - h("div", { class: "card-head" }, h("h2", { id: "h-us-break" }, "Per funzione e per servizio"), - infoTip("Il costo è una stima: token (o chiamate) per i prezzi impostati qui sotto. Controlla sempre la fattura del fornitore.")), - items.length ? [table("Funzione", group("feature"), report.features), h("div", { style: { height: "12px" } }), table("Servizio", group("provider"), PROVIDERS)] - : h("p", { class: "secondary" }, "Ancora nessuna chiamata registrata.")); + h("div", { class: "card-head" }, h("h2", { id: "h-us-break" }, t("Per funzione e per servizio")), + infoTip(t("Il costo è una stima: token (o chiamate) per i prezzi impostati qui sotto. Controlla sempre la fattura del fornitore."))), + items.length ? [table(t("Funzione"), group("feature"), report.features), h("div", { style: { height: "12px" } }), table(t("Servizio"), group("provider"), PROVIDERS)] + : h("p", { class: "secondary" }, t("Ancora nessuna chiamata registrata."))); } function settingsCard(ctx, settings) { @@ -120,38 +121,38 @@ function settingsCard(ctx, settings) { }; let actions = null; if (admin) { - const save = h("button", { class: "btn btn-primary", type: "button" }, h("span", { class: "spinner", "aria-hidden": "true" }), "Salva"); + const save = h("button", { class: "btn btn-primary", type: "button" }, h("span", { class: "spinner", "aria-hidden": "true" }), t("Salva")); save.addEventListener("click", async () => { const body = {}; for (const [k, input] of Object.entries(inputs)) { const v = Number(input.value); - if (!(v >= 0)) return toast("I valori devono essere numeri maggiori o uguali a zero", { error: true }); + if (!(v >= 0)) return toast(t("I valori devono essere numeri maggiori o uguali a zero"), { error: true }); body[k] = k === "DAILY_JEV_CALL_LIMIT" ? Math.round(v) : v; } ctx.setBusy(save, true); - try { await api("/usage/settings", { method: "PUT", body }); toast("Limiti e prezzi salvati"); ctx.rerender(); } + try { await api("/usage/settings", { method: "PUT", body }); toast(t("Limiti e prezzi salvati")); ctx.rerender(); } catch (e) { toast(e.message, { error: true }); ctx.setBusy(save, false); } }); - const reset = h("button", { class: "btn btn-ghost", type: "button" }, "Ripristina i valori del .env"); + const reset = h("button", { class: "btn btn-ghost", type: "button" }, t("Ripristina i valori del .env")); reset.addEventListener("click", async () => { - try { await api("/usage/settings/reset", { method: "POST" }); toast("Valori del .env ripristinati"); ctx.rerender(); } + try { await api("/usage/settings/reset", { method: "POST" }); toast(t("Valori del .env ripristinati")); ctx.rerender(); } catch (e) { toast(e.message, { error: true }); } }); actions = h("div", { class: "actions", style: { marginTop: "16px" } }, save, reset); } return h("section", { class: "card", "aria-labelledby": "h-us-set" }, - h("div", { class: "card-head" }, h("h2", { id: "h-us-set" }, "Limiti e prezzi"), - !admin ? h("span", { class: "muted small" }, "Solo un amministratore può modificarli") : null), - h("p", { class: "muted small", style: { marginBottom: "12px" } }, "0 = nessun limite. I prezzi sono in dollari per milione di token: prendili dal listino del tuo piano."), + h("div", { class: "card-head" }, h("h2", { id: "h-us-set" }, t("Limiti e prezzi")), + !admin ? h("span", { class: "muted small" }, t("Solo un amministratore può modificarli")) : null), + h("p", { class: "muted small", style: { marginBottom: "12px" } }, t("0 = nessun limite. I prezzi sono in dollari per milione di token: prendili dal listino del tuo piano.")), h("div", { class: "settings-grid" }, - num("DAILY_JEV_CALL_LIMIT", "Chiamate a Jev al giorno", { step: "1" }), - num("DAILY_AI_BUDGET_USD", "Budget giornaliero", { suffix: "$" }), - num("JEV_PRICE_PER_CALL", "Jev: prezzo per chiamata", { step: "0.001", suffix: "$" }), - num("JEV_PRICE_INPUT_MTOK", "Jev: token in", { suffix: "$/M" }), - num("JEV_PRICE_OUTPUT_MTOK", "Jev: token out", { suffix: "$/M" }), - num("GROQ_PRICE_INPUT_MTOK", "Groq: token in", { suffix: "$/M" }), - num("GROQ_PRICE_OUTPUT_MTOK", "Groq: token out", { suffix: "$/M" }), - num("GEMINI_PRICE_INPUT_MTOK", "Gemini: token in", { suffix: "$/M" }), - num("GEMINI_PRICE_OUTPUT_MTOK", "Gemini: token out", { suffix: "$/M" })), + num("DAILY_JEV_CALL_LIMIT", t("Chiamate a Jev al giorno"), { step: "1" }), + num("DAILY_AI_BUDGET_USD", t("Budget giornaliero"), { suffix: "$" }), + num("JEV_PRICE_PER_CALL", t("Jev: prezzo per chiamata"), { step: "0.001", suffix: "$" }), + num("JEV_PRICE_INPUT_MTOK", t("Jev: token in"), { suffix: "$/M" }), + num("JEV_PRICE_OUTPUT_MTOK", t("Jev: token out"), { suffix: "$/M" }), + num("GROQ_PRICE_INPUT_MTOK", t("Groq: token in"), { suffix: "$/M" }), + num("GROQ_PRICE_OUTPUT_MTOK", t("Groq: token out"), { suffix: "$/M" }), + num("GEMINI_PRICE_INPUT_MTOK", t("Gemini: token in"), { suffix: "$/M" }), + num("GEMINI_PRICE_OUTPUT_MTOK", t("Gemini: token out"), { suffix: "$/M" })), actions); } diff --git a/scripts/check_env.py b/scripts/check_env.py index 7d734f1..1985570 100644 --- a/scripts/check_env.py +++ b/scripts/check_env.py @@ -9,9 +9,9 @@ from backend.db.database import engine from sqlalchemy import text -print(f"Groq API Key configurata: {bool(settings.GROQ_API_KEY and not settings.GROQ_API_KEY.startswith('your_'))}") -print(f"TypeSafe Key configurata: {bool(settings.TYPESAFE_API_KEY and not settings.TYPESAFE_API_KEY.startswith('your_'))}") -print(f"Gemini Key configurata: {bool(settings.GEMINI_API_KEY and not settings.GEMINI_API_KEY.startswith('your_'))}") +print(f"Groq API key configured: {bool(settings.GROQ_API_KEY and not settings.GROQ_API_KEY.startswith('your_'))}") +print(f"TypeSafe key configured: {bool(settings.TYPESAFE_API_KEY and not settings.TYPESAFE_API_KEY.startswith('your_'))}") +print(f"Gemini key configured: {bool(settings.GEMINI_API_KEY and not settings.GEMINI_API_KEY.startswith('your_'))}") async def test_conn(): try: diff --git a/tests/conftest.py b/tests/conftest.py index f9824b1..370f144 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -17,6 +17,8 @@ for key in ("TYPESAFE_API_KEY", "GEMINI_API_KEY", "GROQ_API_KEY"): os.environ[key] = "" os.environ["OLLAMA_BASE_URL"] = "http://127.0.0.1:9" +# The existing tests check the Italian texts; tests/test_i18n.py covers English +os.environ["APP_LANGUAGE"] = "it" # Fast client-side rate limits in tests (test_ratelimit.py sets its own) os.environ["JEV_RPM"] = "6000" os.environ["GROQ_RPM"] = "6000" diff --git a/tests/test_i18n.py b/tests/test_i18n.py new file mode 100644 index 0000000..3695c66 --- /dev/null +++ b/tests/test_i18n.py @@ -0,0 +1,68 @@ +"""Texts in the language the dashboard asks for (X-Lang), APP_LANGUAGE otherwise.""" +import re +from pathlib import Path + +from backend.config import settings +from backend.i18n import dec, dollars, lang, reset_lang, set_lang, tr +from tests.conftest import login_client +from tests.test_strategy import fc, plan + +ROOT = Path(__file__).resolve().parent.parent + + +def in_lang(value, fn): + token = set_lang(value) + try: + return fn() + finally: + reset_lang(token) + + +def test_language_from_request_then_setting(monkeypatch): + assert lang() == "it" # tests run with APP_LANGUAGE=it + assert in_lang("en", lang) == "en" and in_lang("EN-gb", lang) == "en" + assert in_lang("fr", lang) == "it" # unknown: the setting + monkeypatch.setattr(settings, "APP_LANGUAGE", "en") + assert lang() == "en" and in_lang("it", lang) == "it" + assert in_lang("en", lambda: (tr("Sì", "Yes"), dec(1.25, 1), dollars(41.02))) == ("Yes", "1.2", "$41.02") + assert in_lang("it", lambda: (tr("Sì", "Yes"), dec(1.5, 1), dollars(41.02))) == ("Sì", "1,5", "41,02 $") + + +def test_plan_in_english(): + p = in_lang("en", lambda: plan(fc(), 0.35, "BUY_YES", tally={"raises_yes": 1, "lowers_yes": 0, "neutral": 0})) + assert p.title == "Buy YES" and p.summary.startswith("Limit order:") and "$" in p.summary + assert any("news item points this way" in r for r in p.pros) + assert any("chance of losing" in r for r in p.cons) + assert not re.search(r"\d,\d", p.summary) # decimal point, not comma + text = " ".join([p.title, p.summary, *p.pros, *p.cons, *p.exit, *p.confidence_why]) + assert not re.search(r"\b(probabilità|prezzo|notizie|quote)\b", text) + + +async def test_api_errors_follow_x_lang(db): + async with login_client("admin") as client: + r = await client.post("/markets/does-not-exist/predict", headers={"X-Lang": "en"}) + assert r.status_code == 404 and r.json()["detail"] == "Market not found" + r = await client.post("/markets/does-not-exist/predict", headers={"X-Lang": "it"}) + assert r.json()["detail"] == "Mercato non trovato" + r = await client.get("/portfolio", headers={"X-Lang": "en"}) + assert r.status_code == 200 and {p["label"] for p in r.json()["presets"]} == {"Prudent", "Balanced", "Aggressive"} + + +def test_every_ui_text_has_an_english_translation(): + """Every literal passed to t() in the dashboard is in i18n-en.js, with the same placeholders.""" + frontend = ROOT / "frontend" + dictionary = (frontend / "i18n-en.js").read_text(encoding="utf-8") + entries = dict(re.findall(r'^ ("(?:[^"\\]|\\.)*"): ("(?:[^"\\]|\\.)*"),$', dictionary, re.M)) + import json + en = {json.loads(k): json.loads(v) for k, v in entries.items()} + missing = set() + for path in [*frontend.glob("*.js"), *frontend.glob("views/*.js")]: + if path.name.startswith("i18n"): + continue + for m in re.finditer(r'\bt\("((?:[^"\\]|\\.)*)"', path.read_text(encoding="utf-8")): + key = json.loads(f'"{m.group(1)}"') + if key not in en: + missing.add(key) + elif sorted(re.findall(r"\{\d+\}", key)) != sorted(re.findall(r"\{\d+\}", en[key])): + missing.add(f"placeholders: {key}") + assert not missing, sorted(missing)[:20]