Files

149 lines
8.2 KiB
Python

#!/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 <nom|ticker>... → « ORA.PA (ORANGE) : 13,87 € (-2,50 % sur la séance) », une ligne par titre
# graphe <nom|ticker>... [--periode P] [--zoom|--zero]
# → un PNG par titre via le webhook N8N, imprime « MEDIA:<chemin> » (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 <nom|ticker> [--cloture] / retirer <nom|ticker> [--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 <titre>... | graphe <titre>... [--periode jour|semaine|mois|trimestre|semestre|an] [--zoom|--zero] | cloture | liste | ajouter|retirer <titre> [--cloture]")