Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions docs/audit/SAXO_VALUATION_PLAN_2026-10-05.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,17 @@ Date : 5 octobre 2026. Statut : plan validé par l'utilisateur ; implémentation

Commandes de contrôle : activation de `.venv`, puis pytest ciblé sur les deux nouveaux fichiers de tests, les exports, FX, isolation Saxo et import du prix moyen, avec `--no-cov`; `node tests/e2e/saxo-valuation-check.cjs`; vérifications de syntaxe Python/JavaScript et `git diff --check`.

## Contrôle Robot2 du 6 octobre 2026 et correctif de compatibilité

- Le checkout Robot2 et le conteneur utilisent `1f156c50` (PR #70) ; API et Redis sains. Les 13 fichiers applicatifs contrôlés et les ressources statiques servies correspondent à la livraison après normalisation CRLF/LF. Les modifications locales préexistantes du serveur restent préservées.
- API authentifiée : bon CSV sélectionné, totaux courant/global/export concordants, référence EUR fidèle au CSV, cash historique non vérifié exclu, CSV inchangé. Refus sans token 401, identité incohérente 403 et CSV absent 404.
- Défaut réel : `yfinance` 1.7.0 sur Robot2 retourne des `pandas.Timestamp` pour le cours et les bornes de séance, alors que le checkout initial avait été qualifié avec des secondes epoch (`yfinance` 1.0). `int(Timestamp)` échoue ; les 30 positions retombent sur la référence CSV, avec état partiel explicite. Un conteneur sain ne suffisait donc pas à qualifier les cours.
- Correctif : normaliser secondes epoch et dates avec fuseau en UTC ; refuser une date sans fuseau. Conserver le cours daté si seules les bornes de séance sont inutilisables. Tests dédiés aux trois formats : nombre, datetime et pandas.Timestamp.
- Alias exact UBS `WRDUSW_CHF` sur SIX : `WRDUSW.SW`, uniquement avec ISIN `IE00BD4TXV59` et cotation CHF. [La fiche UBS](https://swissfunddata.ch/sfdpub/docs/fsm-8522_03_03-20240630-en.pdf) établit le lien ISIN/ticker/share class ; la devise CHF actuelle est vérifiée dans les métadonnées Yahoo du 6 octobre 2026. La fiche datée de 2024 indiquait USD ; elle ne prouve pas la devise actuelle. `WRDUSY.SW` représente une autre part (distribution), et ne constitue pas un substitut.
- 40 tests ciblés réussis et lint réussi. Qualification du correctif en mémoire dans un processus isolé du conteneur : 30/30 cours, aucune valeur manquante, quantités contrôlées, somme positions/cash et export cohérents, CSV inchangé. Cache de contrôle temporaire séparé ; aucun fichier applicatif ou réglage de production modifié. Le JWT de contrôle éphémère et les montants restent sur Robot2 ; seuls compteurs et booléens sont rapatriés.
- Navigateur réel du conteneur : mode historique EUR, cash historique exclu, état partiel visible, Refresh appelle `force=true`, aucun défaut JavaScript. Débordement mobile observé dans l’en-tête avec le vrai sélecteur de vue ; correction par flex-wrap des groupes et largeur bornée. La correction CSS est qualifiée via une réponse CSS substituée uniquement dans le navigateur isolé : desktop/mobile sans débordement. Contrat navigateur local renforcé avec la largeur du sélecteur ; réussi.
- Cette qualification du correctif en mémoire ne constitue pas un redéploiement. Reconstruire le service avec Compose v2 puis refaire le contrôle authentifié avant de conclure que la production est corrigée.

## Objectif et décisions proposées

Afficher par défaut la valorisation estimée des positions importées aux derniers cours disponibles, avec une vue `At export` fidèle au fichier. Conserver les deux bases distinctes et afficher leurs dates, devises, sources et limites.
Expand Down
32 changes: 31 additions & 1 deletion services/saxo_quote_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import time
from datetime import date, datetime, timedelta, timezone
from pathlib import Path
from numbers import Real

from filelock import FileLock

Expand Down Expand Up @@ -36,7 +37,32 @@
}


# Alias Saxo exact, qualifié par ISIN et devise ; aucune suppression générique de suffixe.
UBS_WORLD_CHF = {
'symbol': 'WRDUSW.SW', 'currency': 'CHF', 'isin': 'IE00BD4TXV59',
'source': 'https://swissfunddata.ch/sfdpub/docs/fsm-8522_03_03-20240630-en.pdf',
}
LISTING_ALIASES = {f'WRDUSW_CHF:{mic}': UBS_WORLD_CHF for mic in ('xswx', 'xvtx')}


def _market_timestamp(value) -> int:
"""Accept epoch seconds and timezone-aware Yahoo datetime/Timestamp metadata."""
if isinstance(value, datetime):
if value.tzinfo is None or value.utcoffset() is None:
raise ValueError('Market timestamp timezone unavailable')
seconds = value.timestamp()
elif isinstance(value, Real) and not isinstance(value, bool):
seconds = float(value)
else:
raise ValueError('Invalid market timestamp')
if not math.isfinite(seconds) or seconds <= 0:
raise ValueError('Invalid market timestamp')
return int(seconds)


def yahoo_symbol(symbol: str) -> str:
if symbol in LISTING_ALIASES:
return LISTING_ALIASES[symbol]['symbol']
base, sep, mic = symbol.partition(':')
if not base or any(ch in base for ch in '/\\'):
raise ValueError('Invalid instrument identifier')
Expand Down Expand Up @@ -77,6 +103,7 @@ def fetch_yahoo_quote(symbol: str, export_date: str | None) -> dict:
stamp = metadata.get('regularMarketTime')
if not currency or not stamp or price is None or not math.isfinite(float(price)) or float(price) <= 0:
raise ValueError('Quote currency, price or market timestamp unavailable')
stamp = _market_timestamp(stamp)
factor = 1.0
if export_date:
if 'Stock Splits' not in history:
Expand All @@ -92,7 +119,10 @@ def fetch_yahoo_quote(symbol: str, export_date: str | None) -> dict:
session = metadata.get('currentTradingPeriod', {}).get('regular', {})
quote_type = 'latest_available'
if session.get('start') and session.get('end'):
quote_type = 'intraday' if session['start'] <= int(stamp) < session['end'] else 'last_close'
try:
quote_type = 'intraday' if _market_timestamp(session['start']) <= stamp < _market_timestamp(session['end']) else 'last_close'
except ValueError:
pass # Un cours réel reste daté même si les bornes de séance sont inexploitables.
# Les splits ne prouvent pas l'absence de fusion, scission ou changement de titre
# sur plusieurs années. Ces quantités restent des estimations explicites.
quantity_verified = bool(export_date)
Expand Down
10 changes: 8 additions & 2 deletions services/saxo_valuation_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@

from connectors.saxo_import import SaxoImportConnector
from services import fx_service
from services.saxo_quote_service import get_quote, yahoo_symbol
from services.saxo_quote_service import LISTING_ALIASES, get_quote, yahoo_symbol

ROOT = Path(__file__).resolve().parents[1]
MONTHS = dict(zip(('janv', 'fevr', 'mars', 'avr', 'mai', 'juin', 'juil', 'aout', 'sept', 'oct', 'nov', 'dec'), range(1, 13)))
Expand Down Expand Up @@ -119,7 +119,13 @@ def value_reference(reference: dict, mode: str = 'current', currency: str = 'USD
supported_symbols = {p['symbol'] for p in positions if p['asset_class'] not in {'Bond', 'CFD', 'Option', 'Warrant'}}
def fetch(symbol):
try:
return symbol, get_quote(yahoo_symbol(symbol), export_date, force)
alias = LISTING_ALIASES.get(symbol)
if alias and any(p.get('isin') != alias['isin'] for p in positions if p['symbol'] == symbol):
return symbol, None
quote = get_quote(yahoo_symbol(symbol), export_date, force)
if alias and quote and quote['currency'] != alias['currency']:
return symbol, None
return symbol, quote
except (ValueError, OSError, TimeoutError):
return symbol, None
with ThreadPoolExecutor(max_workers=4) as pool:
Expand Down
8 changes: 8 additions & 0 deletions static/css/saxo-dashboard.css
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@

.dashboard-header {
display: flex;
flex-wrap: wrap;
gap: 1rem;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
Expand All @@ -37,6 +39,12 @@
border: 1px solid var(--theme-border);
}

.dashboard-header > div {
flex-wrap: wrap;
min-width: 0;
max-width: 100%;
}

.dashboard-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
Expand Down
2 changes: 1 addition & 1 deletion tests/e2e/saxo-valuation-check.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ function fixture(mode, partial = false) {
if (url.pathname === '/saxo-dashboard.html') {
let html = fs.readFileSync(path.join(root, 'static/saxo-dashboard.html'), 'utf8');
html = html.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, script => script.includes('let currentPortfolioData') ? script : '');
html = html.replace('<head>', `<head><script>
html = html.replace('<head>', `<head><style>view-toggle { display: inline-block; width: 160px; }</style><script>
localStorage.setItem('activeUser', 'alice'); localStorage.setItem('bourseSource', 'saxo:old.csv');
window.getApiBase = () => ''; window.globalConfig = { getApiUrl: path => path };
window.debugLogger = { debug(){}, info(){}, warn(){}, error(...args){ console.error(...args); } };
Expand Down
37 changes: 37 additions & 0 deletions tests/unit/test_saxo_valuation_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -286,3 +286,40 @@ def verified(currency):
assert rate == pytest.approx(0.8)
assert info['fresh']
assert calls == ['EUR', 'CHF']


@pytest.mark.parametrize('kind', ['epoch', 'datetime', 'pandas'])
def test_provider_supports_old_and_new_yahoo_timestamp_metadata(monkeypatch, kind):
import yfinance
timestamp = int(datetime.now(timezone.utc).timestamp())
def formatted(value):
if kind == 'epoch': return value
aware = datetime.fromtimestamp(value, timezone.utc)
return pd.Timestamp(aware).tz_convert('America/New_York') if kind == 'pandas' else aware
history = pd.DataFrame({'Stock Splits': [0]}, index=pd.to_datetime(['2026-09-22']))
class Ticker:
def history(self, **kwargs): return history
def get_history_metadata(self):
return {'currency': 'USD', 'regularMarketPrice': 100, 'regularMarketTime': formatted(timestamp),
'currentTradingPeriod': {'regular': {'start': formatted(timestamp-100), 'end': formatted(timestamp+100)}}}
monkeypatch.setattr(yfinance, 'Ticker', lambda _symbol: Ticker())
quote = quotes.fetch_yahoo_quote('MSFT', '2026-09-22')
assert quote['quote_at'] == datetime.fromtimestamp(timestamp, timezone.utc).isoformat()
assert quote['quote_type'] == 'intraday'


def test_ambiguous_naive_market_timestamp_is_not_promoted():
with pytest.raises(ValueError, match='timezone'):
quotes._market_timestamp(datetime(2026, 10, 5, 16, 0))


@pytest.mark.parametrize('isin,currency,accepted', [
('IE00BD4TXV59', 'CHF', True), ('IE00BD4TXV59', 'USD', False), ('wrong', 'CHF', False), (None, 'CHF', False)])
def test_exact_ubs_chf_alias_checks_isin_and_quote_currency(reference, current_quotes, isin, currency, accepted):
reference['positions'][0].update(symbol='WRDUSW_CHF:xswx', isin=isin)
current_quotes['currency'] = currency
assert quotes.yahoo_symbol('WRDUSW_CHF:xswx') == 'WRDUSW.SW'
assert quotes.yahoo_symbol('WRDUSW_CHF:xvtx') == 'WRDUSW.SW'
assert quotes.yahoo_symbol('WRDUSW_CHF:xlon') == 'WRDUSW_CHF.L'
result = valuation.value_reference(reference)
assert (result['positions'][0]['price_source'] == 'yahoo') == accepted
Loading