#!/usr/bin/env python3 # bourse.py — SEUL outil de l'agent bourse (10/10/2026, « GO agent bourse »). Le code calcule, l'agent relaie. # cours ... → « ORA.PA (ORANGE) : 13,87 € (-2,50 % sur la séance) », une ligne par titre # graphe ... [--periode P] [--zoom|--zero] # → un PNG par titre via le webhook N8N, imprime « MEDIA: » (ligne seule = image jointe) # cloture → les graphes de close_bilan.images (cron 17:40), mêmes lignes MEDIA: # liste → la watchlist et le bilan de clôture # ajouter [--cloture] / retirer [--cloture] # → modifie bourse-config.json (seule écriture du script) # Périodes : jour (1d/15m), semaine (5d/60m), mois (1mo/1d), trimestre (3mo/1d), semestre (6mo/1d), an (1y/1d). # Un titre par graphe : le webhook n'a pas de normalisation, deux prix d'échelles différentes se lisent mal. import json, os, re, sys, time, urllib.request, urllib.error WS = "/home/node/.openclaw/workspace-bourse" CONF = os.environ.get("BOURSE_CONF", os.path.join(WS, "bourse-config.json")) # tests : copie CHARTS = os.path.join(WS, "charts") YAHOO = "https://query1.finance.yahoo.com/v8/finance/chart/{}?interval=1d&range=1d" UA = {"User-Agent": "Mozilla/5.0"} # sans navigateur déclaré, Yahoo répond vide PERIODES = {"jour": ("1d", "15m"), "semaine": ("5d", "60m"), "mois": ("1mo", "1d"), "trimestre": ("3mo", "1d"), "semestre": ("6mo", "1d"), "an": ("1y", "1d")} DEVISES = {"EUR": "€", "USD": "$", "GBP": "£"} def conf(): return json.load(open(CONF)) def sauver(c): json.dump(c, open(CONF, "w"), indent=2, ensure_ascii=False) def ticker(nom): """Nom courant ou ticker → symbole Yahoo ; None si non coté ; str commençant par '?' si inconnu.""" n = nom.strip().lower() al = conf().get("ticker_aliases", {}) if n in al: return None if al[n] == "non_cote_en_bourse" else al[n] # Ticker écrit EN MAJUSCULES (NVDA, ORA.PA) ; un mot en minuscules hors alias (« truc ») est inconnu. if re.fullmatch(r"[A-Z0-9^=-]{1,10}(\.[A-Z]{1,3})?", nom.strip()): return nom.strip() return "?" + nom def nombre(x, d=2): return f"{x:,.{d}f}".replace(",", " ").replace(".", ",") def yahoo(sym): r = urllib.request.urlopen(urllib.request.Request(YAHOO.format(sym), headers=UA), timeout=20) res = json.load(r)["chart"]["result"] if not res: raise ValueError("aucune donnée") return res[0]["meta"] def cours(noms): for nom in noms: s = ticker(nom) if s is None: print(f"{nom} : non coté en bourse."); continue if s.startswith("?"): print(f"{nom} : titre inconnu, donne son ticker (ex. ORA.PA, NVDA)."); continue try: m = yahoo(s) except Exception as e: print(f"{s} : cours indisponible ({e}). Aucun chiffre."); continue prix, dev = m.get("regularMarketPrice"), DEVISES.get(m.get("currency"), m.get("currency", "")) if prix is None: print(f"{s} : cours indisponible. Aucun chiffre."); continue pct = m.get("regularMarketChangePercent") if pct is None and m.get("chartPreviousClose"): pct = (prix / m["chartPreviousClose"] - 1) * 100 heure = time.strftime("%d/%m %H:%M", time.localtime(m["regularMarketTime"])) if m.get("regularMarketTime") else "?" var = f" ({'+' if pct >= 0 else ''}{nombre(pct)} % sur la séance)" if pct is not None else "" print(f"{s} ({m.get('shortName') or m.get('longName') or s}) : {nombre(prix)} {dev}{var} — cotation de {heure}") def un_graphe(sym, rng, itv, zero, fichier): body = json.dumps({"tickers": [sym], "range": rng, "interval": itv, "beginAtZero": zero, "title": sym}).encode() try: r = urllib.request.urlopen(urllib.request.Request(conf()["webhook"], data=body, method="POST", headers={"Content-Type": "application/json"}), timeout=120) data = r.read() except Exception as e: print(f"{sym} : graphe indisponible ({e})."); return if len(data) < 5000 or not data.startswith(b"\x89PNG"): print(f"{sym} : pas de données (marché fermé ou ticker invalide). Aucun graphe."); return os.makedirs(CHARTS, exist_ok=True) chemin = os.path.join(CHARTS, fichier); open(chemin, "wb").write(data) print(f"MEDIA:{chemin}") # ligne seule relayée telle quelle = image jointe (protocole OpenClaw) def graphe(args): per, zero, noms = "semaine", False, [] i = 0 while i < len(args): a = args[i] if a == "--periode" and i + 1 < len(args): per = args[i + 1].lower(); i += 2; continue if a == "--zero": zero = True elif a == "--zoom": zero = False else: noms.append(a) i += 1 if per not in PERIODES: print(f"Période inconnue « {per} » : {', '.join(PERIODES)}."); return if not noms: noms = conf().get("tickers", []) rng, itv = PERIODES[per] for nom in noms: s = ticker(nom) if s is None: print(f"{nom} : non coté en bourse."); continue if s.startswith("?"): print(f"{nom} : titre inconnu, donne son ticker (ex. ORA.PA, NVDA)."); continue un_graphe(s, rng, itv, zero, f"{re.sub(r'[^a-z0-9]', '_', s.lower())}_{per}.png") def cloture(): imgs = conf().get("close_bilan", {}).get("images", []) for im in imgs: for f in [im.get("file")]: if f: p = os.path.join(CHARTS, f) if os.path.exists(p): os.remove(p) n = 0 for im in imgs: tk = im.get("tickers") or [] if len(tk) != 1 or not im.get("file"): print(f"Entrée de clôture ignorée (un seul ticker et un fichier) : {im}"); continue un_graphe(tk[0], "1mo", "1d", False, im["file"]); n += 1 if not n: print("Bilan de clôture vide : rien à envoyer.") def liste(): c = conf() print("Watchlist : " + ", ".join(c.get("tickers", []))) print("Bilan de clôture (17:40, jours ouvrés) : " + ", ".join(t for im in c.get("close_bilan", {}).get("images", []) for t in im.get("tickers", []))) def modifier(args, ajout): clot = "--cloture" in args; noms = [a for a in args if a != "--cloture"] if len(noms) != 1: print("Un seul titre à la fois."); return s = ticker(noms[0]) if s is None: print(f"{noms[0]} : non coté en bourse, rien de changé."); return if s.startswith("?"): print(f"{noms[0]} : titre inconnu, donne son ticker. Rien de changé."); return if ajout: try: yahoo(s) except Exception: print(f"{s} : Yahoo ne connaît pas ce ticker. Rien de changé."); return c = conf() if clot: imgs = c.setdefault("close_bilan", {}).setdefault("images", []) dedans = [im for im in imgs if im.get("tickers") == [s]] if ajout and dedans: print(f"{s} est déjà dans le bilan de clôture."); return if not ajout and not dedans: print(f"{s} n'est pas dans le bilan de clôture."); return if ajout: imgs.append({"type": "prix", "tickers": [s], "note": "zoom variations", "file": f"prix_{re.sub(r'[^a-z0-9]', '_', s.lower())}.png"}) else: c["close_bilan"]["images"] = [im for im in imgs if im.get("tickers") != [s]] lieu = "bilan de clôture" else: tk = c.setdefault("tickers", []) if ajout and s in tk: print(f"{s} est déjà dans la watchlist."); return if not ajout and s not in tk: print(f"{s} n'est pas dans la watchlist."); return if ajout: tk.append(s) else: tk.remove(s) lieu = "watchlist" sauver(c) art = ("au " if ajout else "du ") if lieu.startswith("bilan") else ("à la " if ajout else "de la ") print(f"C'est noté : {s} {'ajouté' if ajout else 'retiré'} {art}{lieu}.") a = sys.argv[1:] cmd, reste = (a[0], a[1:]) if a else ("", []) if cmd == "cours" and reste: cours(reste) elif cmd == "graphe": graphe(reste) elif cmd == "cloture": cloture() elif cmd == "liste": liste() elif cmd == "ajouter": modifier(reste, True) elif cmd == "retirer": modifier(reste, False) else: print("usage : bourse.py cours ... | graphe ... [--periode jour|semaine|mois|trimestre|semestre|an] [--zoom|--zero] | cloture | liste | ajouter|retirer [--cloture]")