Compare commits

...

13 Commits

45 changed files with 5231 additions and 1358 deletions
+19
View File
@@ -0,0 +1,19 @@
{
"name": "bourse",
"dockerComposeFile": "../docker-compose.yml",
"service": "web",
"workspaceFolder": "/app",
"remoteUser": "appuser",
"customizations": {
"vscode": {
"extensions": [
"ms-python.python",
"ms-python.vscode-pylance"
],
"settings": {
"python.defaultInterpreterPath": "/usr/local/bin/python"
}
}
}
}
+28
View File
@@ -0,0 +1,28 @@
# VCSP
.git
.gitignore
# Environnement & secrets
.env
.env.*
# Bases de données locales
mysql_data/
*.sqlite3
# Python
__pycache__/
*.py[cod]
venv/
env/
ENV/
# IDE
.vscode/
.idea/
.DS_Store
# Docker
#Dockerfile
#docker-compose.yml
.dockerignore
+4
View File
@@ -16,5 +16,9 @@ __pycache__/
# Fichiers de base de données locaux (si applicable) # Fichiers de base de données locaux (si applicable)
*.sqlite3 *.sqlite3
# Fichiers de configuration locaux
.env
.env.*
# Base de données MySQL Docker # Base de données MySQL Docker
mysql_data/ mysql_data/
+231
View File
@@ -0,0 +1,231 @@
# AGENTS.md — Projet Bourse
## Description du projet
Application de gestion boursière permettant :
* le suivi des actions et titres financiers ;
* la gestion des ordres d'achat et de vente ;
* l'importation de données financières ;
* la consultation via une interface web et des API.
Le projet doit rester maintenable, sécurisé et compatible avec l'environnement Docker existant.
---
# Architecture technique
## Environnement
Système de développement :
* Ubuntu Linux
* Python 3.x
* environnement virtuel `.venv`
* Docker / Docker Compose
## Technologies principales
Backend :
* Python
* Flask
* API REST
Base de données :
* MySQL 8
* phpMyAdmin pour administration
Frontend :
* HTML
* CSS 
* JavaScript
* Tailwind
Déploiement :
* Docker
* conteneurs séparés pour application et base de données
---
# Règles générales de développement
## Avant toute modification
Toujours :
1. analyser le code existant ;
2. expliquer la cause du problème ;
3. proposer une modification ;
4. attendre validation avant les changements importants.
Ne jamais réécrire une partie complète du projet sans justification.
---
# Python
Respecter :
* PEP8 ;
* code lisible et commenté en Français ;
* fonctions courtes ;
* gestion correcte des exceptions.
Éviter :
* duplication de code ;
* variables globales inutiles ;
* modifications qui cassent l'existant.
Toujours utiliser l'environnement virtuel :
```
.venv
```
---
# Flask
Respecter l'organisation actuelle du projet.
Avant modification :
* identifier les routes existantes ;
* vérifier les dépendances ;
* vérifier les appels frontend/API.
Les nouvelles routes doivent :
* avoir une validation des entrées ;
* retourner des erreurs explicites ;
* utiliser les codes HTTP appropriés.
---
# Base de données MySQL
Règles :
* ne jamais supprimer une table sans confirmation ;
* ne jamais modifier une colonne existante sans vérifier les impacts ;
* toujours proposer une migration SQL.
Respecter les conventions :
* noms de tables en minuscules ;
* clés primaires explicites ;
* index sur les recherches fréquentes.
Avant une requête SQL complexe :
* expliquer son fonctionnement ;
* vérifier les performances.
---
# Données financières
Attention aux données :
* ticker ;
* ISIN ;
* exchange ;
* pays ;
* devise ;
* cours ;
* ordres.
Ne jamais modifier automatiquement une donnée financière sans conserver la valeur précédente si nécessaire.
---
# Docker
Avant modification :
Vérifier :
```
docker ps
docker compose ps
```
Ne jamais :
* supprimer un volume Docker ;
* supprimer une base ;
* reconstruire tous les conteneurs sans raison.
Toujours expliquer :
* quel conteneur est modifié ;
* pourquoi ;
* comment revenir en arrière.
---
# Tests
Après chaque modification importante :
Tester :
* démarrage de l'application ;
* connexion MySQL ;
* appels API ;
* affichage frontend.
Vérifier les logs :
```
docker logs <container>
```
---
# Style de réponse attendu de l'IA
Quand tu analyses ce projet :
1. commence par expliquer ce que tu observes ;
2. indique les fichiers concernés ;
3. propose une solution ;
4. donne les commandes exactes à exécuter.
Ne pas modifier plusieurs fichiers simultanément sans expliquer pourquoi.
---
# Priorités du projet
Ordre des priorités :
1. stabilité ;
2. conservation des données ;
3. sécurité ;
4. performances ;
5. nouvelles fonctionnalités.
---
# Modèle IA utilisé
Le projet utilise OpenCode avec Ollama local :
Modèle principal :
```
qwen2.5-coder:32b
```
L'IA doit privilégier :
* compréhension du code existant ;
* explications techniques ;
* modifications minimales ;
* solutions compatibles avec l'architecture actuelle.
+13 -2
View File
@@ -2,8 +2,19 @@ FROM python:3.11-slim
WORKDIR /app WORKDIR /app
RUN pip install --no-cache-dir flask flask-sqlalchemy pymysql cryptography requests mysql-connector-python # Installation des dépendances (versions fixées dans requirements.txt)
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copie du code applicatif
COPY run.py .
COPY app ./app
# Utilisateur non-root pour l'exécution
RUN useradd -m appuser && chown -R appuser:appuser /app
USER appuser
EXPOSE 5000 EXPOSE 5000
CMD ["python", "run.py"] #CMD ["python", "run.py"]
CMD ["gunicorn","--bind","0.0.0.0:5000","--workers","3","run:app"]
+56 -10
View File
@@ -1,19 +1,44 @@
import os
import time import time
from flask import Flask, render_template from flask import Flask
from flask_sqlalchemy import SQLAlchemy from flask_sqlalchemy import SQLAlchemy
from flask_wtf import CSRFProtect
from sqlalchemy.exc import OperationalError from sqlalchemy.exc import OperationalError
from dotenv import load_dotenv
load_dotenv()
db = SQLAlchemy() db = SQLAlchemy()
csrf = CSRFProtect()
def create_app(): def create_app():
app = Flask(__name__) app = Flask(__name__)
app.config['SQLALCHEMY_DATABASE_URI'] = 'mysql+pymysql://jfgiraud:lyon4@db/bolsa'
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False # --- Configuration depuis l'environnement ---
app.config['SECRET_KEY'] = 'votre_cle_secrete_tres_securisee_jfgiraud04051963++fin' app.config["SECRET_KEY"] = os.environ.get(
"SECRET_KEY", "change-me-in-production"
)
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
db_user = os.environ.get("MYSQL_USER", "jfgiraud")
db_pass = os.environ.get("MYSQL_PASSWORD", "")
db_host = os.environ.get("MYSQL_HOST", "db")
db_port = os.environ.get("MYSQL_PORT", "3306")
db_name = os.environ.get("MYSQL_DATABASE", "bolsa")
# DATABASE_URL prend la priorité si défini
app.config["SQLALCHEMY_DATABASE_URI"] = os.environ.get(
"DATABASE_URL",
f"mysql+pymysql://{db_user}:{db_pass}@{db_host}:{db_port}/{db_name}",
)
db.init_app(app) db.init_app(app)
csrf.init_app(app)
# Tentative de connexion et création des tables au démarrage du conteneur # Enregistrement des modèles ORM (nécessaire avant db.create_all)
from app import models # noqa: F401
# Tentative de connexion au démarrage du conteneur (MySQL peut mettre du temps à être prêt)
with app.app_context(): with app.app_context():
max_retries = 15 max_retries = 15
delay = 2 delay = 2
@@ -22,17 +47,38 @@ def create_app():
db.create_all() db.create_all()
print("Base de données connectée et tables initialisées avec succès.") print("Base de données connectée et tables initialisées avec succès.")
break break
except OperationalError as e: except OperationalError:
print(f"Tentative {i+1}/{max_retries} : MySQL n'est pas encore prêt. Nouvelle tentative dans {delay}s...") print(f"Tentative {i+1}/{max_retries} : MySQL n'est pas encore prêt. "
f"Nouvelle tentative dans {delay}s...")
time.sleep(delay) time.sleep(delay)
else: else:
raise Exception("Impossible d'initialiser la base de données après plusieurs tentatives.") raise Exception("Impossible d'initialiser la base de données après plusieurs tentatives.")
# Enregistrement des routes (Blueprint) # Enregistrement des routes (Blueprints)
from app.routes import main from app.routes import (
app.register_blueprint(main) actions_bp,
analyse_bp,
auth_bp,
comptes_bp,
historique_bp,
menu_bp,
ordres_bp,
societes_bp,
utilisateurs_bp,
)
app.register_blueprint(auth_bp)
app.register_blueprint(menu_bp)
app.register_blueprint(utilisateurs_bp)
app.register_blueprint(actions_bp)
app.register_blueprint(societes_bp)
app.register_blueprint(historique_bp)
app.register_blueprint(analyse_bp)
app.register_blueprint(ordres_bp)
app.register_blueprint(comptes_bp)
return app return app
# Expose 'app' pour que run.py puisse l'importer directement # Expose 'app' pour que run.py puisse l'importer directement
app = create_app() app = create_app()
+13
View File
@@ -0,0 +1,13 @@
from functools import wraps
from flask import redirect, session, url_for
def login_required(f):
@wraps(f)
def decorated(*args, **kwargs):
if "user_id" not in session:
return redirect(url_for("auth.index"))
return f(*args, **kwargs)
return decorated
+114
View File
@@ -0,0 +1,114 @@
from datetime import datetime
from app import db
class Login(db.Model):
__tablename__ = "login"
id = db.Column(db.Integer, primary_key=True)
login = db.Column(db.String(50), nullable=False, unique=True)
nom = db.Column(db.String(100), nullable=False)
prenom = db.Column(db.String(100), nullable=False)
mail = db.Column(db.String(255), nullable=False, unique=True)
pass_ = db.Column("pass", db.String(255), nullable=False)
actif = db.Column(db.Boolean, default=True)
creer_le = db.Column(db.DateTime, default=datetime.utcnow)
modifie_le = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
reset_token = db.Column(db.String(255), nullable=True)
reset_token_created_at = db.Column(db.DateTime, nullable=True)
class Action(db.Model):
__tablename__ = "actions"
id = db.Column(db.Integer, primary_key=True)
isin = db.Column(db.String(50), nullable=False, unique=True)
ticker = db.Column(db.String(50))
company_name = db.Column(db.String(255))
exchange = db.Column(db.String(50), nullable=False)
pays = db.Column(db.String(50), nullable=False)
currency = db.Column(db.String(10), nullable=False)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
class Societe(db.Model):
__tablename__ = "societe"
id = db.Column(db.Integer, primary_key=True)
nom = db.Column(db.String(60), nullable=False)
forme = db.Column(db.String(10), nullable=False)
capital = db.Column(db.Integer, nullable=False)
siren = db.Column(db.String(20), nullable=False)
siret = db.Column(db.String(20), nullable=False)
rcs = db.Column(db.String(50), nullable=False)
tva = db.Column(db.String(50), nullable=False)
naf = db.Column(db.String(10), nullable=False)
tel = db.Column(db.String(30), nullable=False)
web = db.Column(db.String(100), nullable=False)
mail = db.Column(db.String(100), nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
comptes = db.relationship("Compte", backref="societe", lazy=True)
class Compte(db.Model):
__tablename__ = "compte"
id = db.Column(db.Integer, primary_key=True)
id_societe = db.Column(db.Integer, db.ForeignKey("societe.id"), nullable=False)
date_operation = db.Column(db.Date, nullable=False)
debit = db.Column(db.Numeric(15, 2), default=0)
credit = db.Column(db.Numeric(15, 2), default=0)
libelle = db.Column(db.Text)
source = db.Column(db.Enum("manuel", "ordre"), nullable=False, default="manuel")
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
class Ordre(db.Model):
__tablename__ = "ordre"
id = db.Column(db.Integer, primary_key=True)
id_societe = db.Column(db.Integer, db.ForeignKey("societe.id"), nullable=False)
isin = db.Column(db.String(50), db.ForeignKey("actions.isin"), nullable=False)
plus_value = db.Column(db.Numeric(19, 4), nullable=False, default=0)
date_plus_value = db.Column(db.Date, nullable=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
pieds = db.relationship("OrdrePied", backref="ordre", lazy=True, cascade="all, delete-orphan")
class OrdrePied(db.Model):
__tablename__ = "ordre_pied"
id = db.Column(db.Integer, primary_key=True)
id_entete = db.Column(db.Integer, db.ForeignKey("ordre.id", ondelete="CASCADE", onupdate="CASCADE"), nullable=False)
date_op = db.Column(db.Date, nullable=False)
quantite = db.Column(db.Integer, nullable=False)
prix_brut = db.Column(db.Numeric(19, 4))
frais_broker = db.Column(db.Numeric(19, 4))
frais_etat = db.Column(db.Numeric(19, 4))
frais_autre = db.Column(db.Numeric(19, 4))
sens = db.Column(db.Enum("achat", "vente"), nullable=False)
position = db.Column(db.Enum("long", "short"), nullable=False)
etat = db.Column(db.Enum("actif", "soldé", "annulé"), nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
class Historique(db.Model):
__tablename__ = "historique"
id = db.Column(db.Integer, primary_key=True)
isin = db.Column(db.String(50), db.ForeignKey("actions.isin"), nullable=False)
date = db.Column(db.Date, nullable=False)
price = db.Column(db.Numeric(19, 4))
open_ = db.Column("open", db.Numeric(19, 4))
hight = db.Column(db.Numeric(19, 4))
low = db.Column(db.Numeric(19, 4))
vol = db.Column(db.Integer)
change = db.Column(db.Numeric(19, 4))
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
-881
View File
@@ -1,881 +0,0 @@
from flask import Blueprint, render_template, request, redirect, url_for, session, flash, Response, jsonify
import time
import requests
import csv
import io
from werkzeug.security import check_password_hash, generate_password_hash
from sqlalchemy import text
from app import db
from datetime import datetime
main = Blueprint('main', __name__)
# Route pour la page d'accueil
@main.route('/', methods=['GET', 'POST'])
def index():
if 'user_id' in session:
return redirect(url_for('main.menu'))
if 'login_attempts' not in session:
session['login_attempts'] = 0
if request.method == 'POST':
user_login = request.form.get('login')
user_pass = request.form.get('pass')
query = text("SELECT id, pass AS user_pass, nom, prenom FROM login WHERE login = :login AND actif = 1")
result = db.session.execute(query, {"login": user_login}).fetchone()
if result and check_password_hash(result.user_pass, user_pass):
session.pop('login_attempts', None)
session['user_id'] = result.id
session['user_name'] = f"{result.prenom} {result.nom}"
return redirect(url_for('main.menu'))
else:
session['login_attempts'] += 1
if session['login_attempts'] >= 3:
session.pop('login_attempts', None)
return redirect('https://giraud-finance.com')
essais_restants = 3 - session['login_attempts']
flash(f"Identifiant ou mot de passe incorrect. Il vous reste {essais_restants} essai(s).", "danger")
return render_template('index.html')
# Route pour la déconnexion
@main.route('/logout')
def logout():
session.clear()
return redirect(url_for('main.index'))
# Route pour le menu principal
@main.route('/menu')
def menu():
if 'user_id' not in session:
return redirect(url_for('main.index'))
return render_template('menu.html')
# Route pour la gestion des utilisateurs
@main.route('/gestion-utilisateurs', methods=['GET', 'POST'])
def gestion_utilisateurs():
if 'user_id' not in session:
return redirect(url_for('main.index'))
if request.method == 'POST':
action = request.form.get('action')
user_id = request.form.get('id')
user_actif = request.form.get('actif', '1')
user_prenom = request.form.get('prenom')
user_nom = request.form.get('nom')
user_login = request.form.get('login')
user_mail = request.form.get('mail')
user_pass = request.form.get('pass')
if action == 'creer':
if user_pass:
hashed_pass = generate_password_hash(user_pass)
query = text("""
INSERT INTO login (login, nom, prenom, mail, pass, actif)
VALUES (:login, :nom, :prenom, :mail, :pass, :actif)
""")
db.session.execute(query, {
"login": user_login,
"nom": user_nom,
"prenom": user_prenom,
"mail": user_mail,
"pass": hashed_pass,
"actif": user_actif
})
db.session.commit()
flash("Utilisateur créé avec succès.", "success")
else:
flash("Le mot de passe est obligatoire pour créer un utilisateur.", "danger")
elif action == 'modifier' and user_id:
if user_pass:
hashed_pass = generate_password_hash(user_pass)
query = text("""
UPDATE login
SET login = :login, nom = :nom, prenom = :prenom, mail = :mail, pass = :pass, actif = :actif
WHERE id = :id
""")
db.session.execute(query, {
"id": user_id,
"login": user_login,
"nom": user_nom,
"prenom": user_prenom,
"mail": user_mail,
"pass": hashed_pass,
"actif": user_actif
})
else:
query = text("""
UPDATE login
SET login = :login, nom = :nom, prenom = :prenom, mail = :mail, actif = :actif
WHERE id = :id
""")
db.session.execute(query, {
"id": user_id,
"login": user_login,
"nom": user_nom,
"prenom": user_prenom,
"mail": user_mail,
"actif": user_actif
})
db.session.commit()
flash("Utilisateur modifié avec succès.", "success")
return redirect(url_for('main.gestion_utilisateurs'))
users_query = text("SELECT id, login, nom, prenom, mail, actif FROM login ORDER BY id ASC")
result = db.session.execute(users_query)
users = []
for row in result:
users.append({
'id': row.id,
'login': row.login if row.login else '',
'nom': row.nom if row.nom else '',
'prenom': row.prenom if row.prenom else '',
'mail': row.mail if row.mail else '',
'actif': row.actif if row.actif is not None else 1
})
return render_template('gestion_utilisateurs.html', users=users)
# Route pour la gestion des actions
@main.route('/gestion-actions', methods=['GET', 'POST'])
def gestion_actions():
if 'user_id' not in session:
return redirect(url_for('main.index'))
if request.method == 'POST':
action_type = request.form.get('action')
action_id = request.form.get('id')
isin = request.form.get('isin') or None
ticker = request.form.get('ticker')
company_name = request.form.get('company_name')
exchange = request.form.get('exchange')
pays = request.form.get('pays')
currency = request.form.get('currency')
if action_type == 'creer':
try:
query = text("""
INSERT INTO actions (isin, ticker, company_name, exchange, pays, currency, updated_at)
VALUES (:isin, :ticker, :company_name, :exchange, :pays, :currency, NOW())
""")
db.session.execute(query, {
"isin": isin, "ticker": ticker, "company_name": company_name,
"exchange": exchange, "pays": pays, "currency": currency
})
db.session.commit()
flash("Action créée avec succès", "success")
except Exception as e:
db.session.rollback()
flash("Erreur lors de la création de l'action.", "danger")
elif action_type == 'modifier' and action_id:
try:
query = text("""
UPDATE actions
SET isin = :isin, ticker = :ticker, company_name = :company_name, exchange = :exchange,
pays = :pays, currency = :currency, updated_at = NOW()
WHERE id = :id
""")
db.session.execute(query, {
"id": action_id, "isin": isin, "ticker": ticker, "company_name": company_name,
"exchange": exchange, "pays": pays, "currency": currency
})
db.session.commit()
flash("Modification effectuée", "success")
except Exception as e:
db.session.rollback()
flash("Erreur lors de la modification.", "danger")
return redirect(url_for('main.gestion_actions'))
actions_query = text("SELECT id, isin, ticker, company_name, exchange, pays, currency, updated_at FROM actions ORDER BY company_name ASC")
result = db.session.execute(actions_query)
actions_list = []
for row in result:
actions_list.append({
'id': row.id,
'isin': row.isin if row.isin else '',
'ticker': row.ticker if row.ticker else '',
'company_name': row.company_name if row.company_name else 'Sans nom',
'exchange': row.exchange if row.exchange else '',
'pays': row.pays if row.pays else '',
'currency': row.currency if row.currency else '',
'updated_at': row.updated_at.strftime('%Y-%m-%d %H:%M:%S') if row.updated_at else ''
})
return render_template('gestion_actions.html', actions=actions_list)
# Route pour la gestion des sociétés
@main.route('/gestion-societes', methods=['GET', 'POST'])
def gestion_societes():
if 'user_id' not in session:
return redirect(url_for('main.index'))
if request.method == 'POST':
action = request.form.get('action')
soc_id = request.form.get('id')
nom = request.form.get('Nom')
forme = request.form.get('Forme')
capital = request.form.get('Capital')
siren = request.form.get('siren')
siret = request.form.get('siret')
rcs = request.form.get('RCS')
tva = request.form.get('TVA')
naf = request.form.get('NAF')
tel = request.form.get('Tel')
web = request.form.get('Web')
mail = request.form.get('Mail')
if action == 'creer':
query = text("""
INSERT INTO societe (Nom, Forme, Capital, siren, siret, RCS, TVA, NAF, Tel, Web, Mail)
VALUES (:nom, :forme, :capital, :siren, :siret, :rcs, :tva, :naf, :tel, :web, :mail)
""")
db.session.execute(query, {
"nom": nom, "forme": forme, "capital": capital,
"siren": siren, "siret": siret, "rcs": rcs,
"tva": tva, "naf": naf, "tel": tel,
"web": web, "mail": mail
})
db.session.commit()
flash("Société créée avec succès.", "success")
elif action == 'modifier' and soc_id:
query = text("""
UPDATE societe
SET Nom = :nom, Forme = :forme, Capital = :capital, siren = :siren,
siret = :siret, RCS = :rcs, TVA = :tva, NAF = :naf,
Tel = :tel, Web = :web, Mail = :mail
WHERE id = :id
""")
db.session.execute(query, {
"id": soc_id, "nom": nom, "forme": forme, "capital": capital,
"siren": siren, "siret": siret, "rcs": rcs,
"tva": tva, "naf": naf, "tel": tel,
"web": web, "mail": mail
})
db.session.commit()
flash("Société modifiée avec succès.", "success")
return redirect(url_for('main.gestion_societes'))
societes_query = text("SELECT id, Nom, Forme, Capital, siren, siret, RCS, TVA, NAF, Tel, Web, Mail FROM societe ORDER BY id ASC")
result = db.session.execute(societes_query)
societes = []
for row in result:
societes.append({
'id': row.id,
'Nom': row.Nom if row.Nom else '',
'Forme': row.Forme if row.Forme else '',
'Capital': row.Capital if row.Capital is not None else 0,
'siren': row.siren if row.siren is not None else '',
'siret': row.siret if row.siret is not None else '',
'RCS': row.RCS if row.RCS else '',
'TVA': row.TVA if row.TVA else '',
'NAF': row.NAF if row.NAF else '',
'Tel': row.Tel if row.Tel else '',
'Web': row.Web if row.Web else '',
'Mail': row.Mail if row.Mail else ''
})
return render_template('gestion_societes.html', societes=societes)
# Routes pour la gestion des imports/exports des actions CSV
@main.route('/gestion-import-export-actions-csv', methods=['GET'])
def gestion_import_export_actions_csv():
if 'user_id' not in session:
return redirect(url_for('main.index'))
return render_template('gestion_import_export_actions_csv.html')
# Route pour l'exportation des actions vers un fichier CSV
@main.route('/export-actions-csv', methods=['GET'])
def export_actions_csv():
if 'user_id' not in session:
return redirect(url_for('main.index'))
try:
query = text("SELECT id, isin, ticker, company_name, exchange, pays, currency, updated_at FROM actions")
result = db.session.execute(query)
output = io.StringIO()
writer = csv.writer(output, delimiter=';', quoting=csv.QUOTE_MINIMAL)
writer.writerow(['id', 'isin', 'ticker', 'company_name', 'exchange', 'pays', 'currency', 'updated_at'])
for row in result:
writer.writerow([
row.id,
row.isin or '',
row.ticker or '',
row.company_name or '',
row.exchange or '',
row.pays or '',
row.currency or '',
row.updated_at.strftime('%Y-%m-%d %H:%M:%S') if row.updated_at else ''
])
output.seek(0)
return Response(
output.getvalue(),
mimetype="text/csv",
headers={"Content-Disposition": "attachment;filename=actions_export.csv"}
)
except Exception as e:
flash("Erreur lors de l'exportation du fichier CSV.", "danger")
return redirect(url_for('main.gestion_import_export_actions_csv'))
# Route pour l'importation des actions depuis un fichier CSV
@main.route('/import-actions-csv', methods=['POST'])
def import_actions_csv():
if 'user_id' not in session:
return redirect(url_for('main.index'))
if 'file' not in request.files:
flash("Aucun fichier sélectionné.", "danger")
return redirect(url_for('main.gestion_import_export_actions_csv'))
file = request.files['file']
if file.filename == '':
flash("Aucun fichier sélectionné.", "danger")
return redirect(url_for('main.gestion_import_export_actions_csv'))
if file and file.filename.endswith('.csv'):
try:
stream = io.TextIOWrapper(file.stream, encoding="utf-8")
csv_reader = csv.reader(stream, delimiter=';')
header = next(csv_reader, None)
if not header:
flash("Le fichier CSV est vide.", "danger")
return redirect(url_for('main.gestion_import_export_actions_csv'))
count = 0
for row in csv_reader:
if len(row) >= 6:
isin = row[0].strip() if row[0] != '' else None
ticker = row[1].strip() if row[1] != '' else None
company_name = row[2].strip() if row[2] != '' else None
exchange = row[3].strip() if row[3] != '' else ''
pays = row[4].strip() if row[4] != '' else ''
currency = row[5].strip() if row[5] != '' else ''
if not isin or not ticker:
continue
query = text("""
INSERT INTO actions (isin, ticker, company_name, exchange, pays, currency, updated_at)
VALUES (:isin, :ticker, :company_name, :exchange, :pays, :currency, NOW())
ON DUPLICATE KEY UPDATE
ticker = :ticker, company_name = :company_name,
exchange = :exchange, pays = :pays, currency = :currency, updated_at = NOW()
""")
db.session.execute(query, {
"isin": isin, "ticker": ticker, "company_name": company_name,
"exchange": exchange, "pays": pays, "currency": currency
})
count += 1
db.session.commit()
flash(f"Importation réussie : {count} actions traitées.", "success")
except Exception as e:
db.session.rollback()
flash(f"Erreur lors de l'importation du fichier : {str(e)}", "danger")
return redirect(url_for('main.gestion_import_export_actions_csv'))
else:
flash("Veuillez fournir un fichier au format .csv valide.", "warning")
return redirect(url_for('main.gestion_import_export_actions_csv'))
# Route pour la gestion des ordres
@main.route("/gestion_ordres", methods=["GET", "POST"])
def gestion_ordres():
if "user_id" not in session:
return redirect(url_for("main.index"))
selected_societe_id = (
request.form.get("societe_id")
or request.args.get("societe_id")
or session.get("selected_societe_id")
)
# Récupération de l'année sélectionnée (par défaut "Tout")
selected_annee = (
request.form.get("annee")
or request.args.get("annee")
or session.get("selected_annee", "Tout")
)
session["selected_annee"] = selected_annee
societes = []
selected_societe = ""
groupes_dict = {}
total_plus_value_globale = 0.0
with db.engine.connect() as connection:
result_soc = connection.execute(
text("SELECT id, Nom FROM societe ORDER BY Nom ASC")
)
societes = [dict(row._mapping) for row in result_soc]
if societes:
if not selected_societe_id:
selected_societe_id = societes[0]["id"]
else:
selected_societe_id = int(selected_societe_id)
session["selected_societe_id"] = selected_societe_id
for soc in societes:
if soc["id"] == selected_societe_id:
selected_societe = soc["Nom"]
break
# --- CALCUL DE LA PLUS-VALUE TOTALE ( GLOBALE OU FILTRÉE PAR ANNÉE ) ---
annee_str = str(selected_annee).strip().lower()
if not annee_str or annee_str == "tout":
query_pv = text("""
SELECT SUM(PlusValue) as total_pv
FROM ordre
WHERE id_societe = :soc_id AND PlusValue IS NOT NULL
""")
res_pv = connection.execute(query_pv, {"soc_id": selected_societe_id}).fetchone()
else:
query_pv = text("""
SELECT SUM(PlusValue) as total_pv
FROM ordre
WHERE id_societe = :soc_id
AND PlusValue IS NOT NULL
AND LEFT(CAST(date_plusvalue AS CHAR), 4) = :annee
""")
res_pv = connection.execute(
query_pv, {"soc_id": selected_societe_id, "annee": annee_str}
).fetchone()
if res_pv and res_pv.total_pv is not None:
total_plus_value_globale = float(res_pv.total_pv)
else:
total_plus_value_globale = 0.0
# --- CHARGEMENT DES ORDRES ---
query = text("""
SELECT o.ID as ordre_id, o.isin, o.PlusValue, o.date_plusvalue,
op.ID as pied_id, op.Date_op, op.Quantite, op.Prix_brut, op.Frais_brocker,
op.Frais_etat, op.Frais_autre, op.Ordre, op.Type, op.Etat,
a.ticker, a.company_name, a.currency
FROM ordre o
JOIN ordre_pied op ON o.ID = op.Id_entete
JOIN actions a ON o.isin = a.isin
WHERE o.id_societe = :soc_id
ORDER BY o.ID DESC, op.Date_op DESC
""")
result_ordres = connection.execute(
query, {"soc_id": selected_societe_id}
)
for row in result_ordres:
r = dict(row._mapping)
ordre_id = r["ordre_id"] # Utilisation de l'ID d'en-tête comme clé unique
isin = r["isin"]
quantite = float(r["Quantite"]) if r["Quantite"] is not None else 0.0
prix_brut = float(r["Prix_brut"]) if r["Prix_brut"] is not None else 0.0
frais_brocker = (
float(r["Frais_brocker"]) if r["Frais_brocker"] is not None else 0.0
)
frais_etat = (
float(r["Frais_etat"]) if r["Frais_etat"] is not None else 0.0
)
frais_autre = (
float(r["Frais_autre"]) if r["Frais_autre"] is not None else 0.0
)
frais_total = frais_brocker + frais_etat + frais_autre
sens_ordre = str(r["Ordre"]).strip().lower()
type_ordre = (
str(r["Type"]).strip().lower()
if r["Type"]
else "long"
)
# Calcul Prix Net unitaire
if sens_ordre == "vente":
prix_net = prix_brut - frais_total
else:
prix_net = prix_brut + frais_total
# Calculs Ligne (Pied)
eng_brut = quantite * prix_brut
eng_net = quantite * prix_net
pied_data = {
"id": r["pied_id"],
"date_op": r["Date_op"],
"quantite": quantite,
"prix_brut": prix_brut,
"frais": frais_total,
"frais_brocker": frais_brocker,
"frais_etat": frais_etat,
"frais_autre": frais_autre,
"prix_net": prix_net,
"eng_brut": eng_brut,
"eng_net": eng_net,
"ordre": r["Ordre"],
"type_ordre": r["Type"],
"etat": r["Etat"],
}
# MODIFICATION ICI : On utilise ordre_id comme clé du dictionnaire pour séparer les blocs
if ordre_id not in groupes_dict:
groupes_dict[ordre_id] = {
"id": ordre_id,
"isin": isin,
"company_name": r["company_name"],
"ticker": r["ticker"],
"pieds": [],
"total_quantite": 0.0,
"total_prix_brut": 0.0,
"total_frais": 0.0,
"total_prix_net": 0.0,
"total_eng_brut": 0.0,
"total_eng_net": 0.0,
"plus_value": r["PlusValue"],
"date_plusvalue": r["date_plusvalue"],
}
groupes_dict[ordre_id]["pieds"].append(pied_data)
groupes_dict[ordre_id]["total_prix_brut"] += prix_brut
groupes_dict[ordre_id]["total_frais"] += frais_total
groupes_dict[ordre_id]["total_prix_net"] += prix_net
# --- APPLICATION DE VOS RÈGLES POUR LES TOTAUX ---
# 1. QUANTITÉ
if sens_ordre == "achat":
groupes_dict[ordre_id]["total_quantite"] += quantite
elif sens_ordre == "vente":
groupes_dict[ordre_id]["total_quantite"] -= quantite
# 2. ENGAGEMENT BRUT
if sens_ordre == "achat":
groupes_dict[ordre_id]["total_eng_brut"] -= eng_brut
elif sens_ordre == "vente":
groupes_dict[ordre_id]["total_eng_brut"] += eng_brut
# 3. ENGAGEMENT NET
if sens_ordre == "achat":
groupes_dict[ordre_id]["total_eng_net"] -= eng_net
elif sens_ordre == "vente":
groupes_dict[ordre_id]["total_eng_net"] += eng_net
# --- VÉRIFICATION ET MISE À JOUR AUTOMATIQUE (EN BASE ET EN MÉMOIRE) ---
try:
with db.engine.begin() as conn:
for ordre_id, groupe in groupes_dict.items():
if abs(groupe["total_quantite"]) < 1e-9:
total_eng_net = groupe["total_eng_net"]
max_date = max((p["date_op"] for p in groupe["pieds"] if p["date_op"] is not None), default=None)
conn.execute(
text(
"UPDATE ordre_pied SET Etat = :nouvel_etat WHERE Id_entete = :ordre_id"
),
{"nouvel_etat": "soldé", "ordre_id": ordre_id},
)
conn.execute(
text(
"UPDATE ordre SET PlusValue = :plus_value, date_plusvalue = :date_pv WHERE ID = :ordre_id"
),
{"plus_value": total_eng_net, "date_pv": max_date, "ordre_id": ordre_id},
)
groupe["plus_value"] = total_eng_net
groupe["date_plusvalue"] = max_date
for pied in groupe["pieds"]:
pied["etat"] = "soldé"
except Exception as e:
pass
groupes_ordres = list(groupes_dict.values())
annees_combo = ["Tout"] + [str(an) for an in range(2020, 2041)]
return render_template(
"gestion_ordres.html",
societes=societes,
selected_societe_id=selected_societe_id,
selected_societe=selected_societe,
groupes_ordres=groupes_ordres,
total_plus_value_globale=total_plus_value_globale,
annees_combo=annees_combo,
selected_annee=selected_annee,
)
# Route pour la creation d'un ordre
@main.route("/creer-ordre", methods=["GET", "POST"])
def creer_ordre():
if "user_id" not in session:
return redirect(url_for("main.index"))
return render_template("creer_ordre.html")
# Route pour vérifier l'existence d'un ISIN
@main.route("/api/verifier_isin", methods=["POST"])
def verifier_isin():
data = request.get_json()
isin = data.get("isin", "").strip()
query = text(
"SELECT company_name, ticker, currency FROM actions WHERE isin = :isin"
)
action = db.session.execute(query, {"isin": isin}).fetchone()
if action:
return jsonify({
"exists": True,
"company_name": action.company_name,
"ticker": action.ticker,
"currency": action.currency,
})
else:
return jsonify({"exists": False})
# Route pour la création d'un ordre de traitement
@main.route("/creer-ordre-traitement", methods=["POST"])
def creer_ordre_traitement():
if "user_id" not in session:
return redirect(url_for("main.index"))
isin = request.form.get("isin")
date_op = request.form.get("date_op")
selected_annee = request.form.get("annee", "tout")
try:
quantite = float(request.form.get("quantite") or 0)
prix_brut = float(request.form.get("prix_brut") or 0)
frais_brocker = float(request.form.get("frais_brocker") or 0)
frais_etat = float(request.form.get("frais_etat") or 0)
frais_autre = float(request.form.get("frais_autre") or 0)
except ValueError:
flash("Veuillez saisir des valeurs numériques valides pour les quantités, prix et frais.", "danger")
return redirect(url_for("main.gestion_ordres", annee=selected_annee))
ordre = request.form.get("ordre")
type_ordre = request.form.get("type_ordre")
etat = request.form.get("etat")
# Récupération de l'id_societe courant stocké dans la session
id_societe = session.get("selected_societe_id")
if not id_societe:
flash(
"Veuillez sélectionner une société active avant de créer un ordre.",
"danger",
)
return redirect(url_for("main.gestion_ordres", annee=selected_annee))
try:
# 1. Insertion dans la table 'ordre' avec l'isin et l'id_societe
query_ordre = text(
"INSERT INTO ordre (id_societe, isin) VALUES (:id_societe, :isin)"
)
db.session.execute(query_ordre, {"id_societe": id_societe, "isin": isin})
# Récupérer l'ID généré pour l'en-tête (Id_entete)
result_id = db.session.execute(
text("SELECT LAST_INSERT_ID()")
).scalar()
if not result_id:
result_id = db.session.execute(
text(
"SELECT ID FROM ordre WHERE isin = :isin ORDER BY ID DESC LIMIT 1"
),
{"isin": isin},
).fetchone()[0]
# 2. Insertion dans la table 'ordre_pied' avec les 3 frais distincts
query_pied = text("""
INSERT INTO ordre_pied (Id_entete, Date_op, Quantite, Prix_brut, Frais_brocker, Frais_etat, Frais_autre, Ordre, Type, Etat)
VALUES (:id_entete, :date_op, :quantite, :prix_brut, :frais_brocker, :frais_etat, :frais_autre, :ordre, :type_ordre, :etat)
""")
db.session.execute(
query_pied,
{
"id_entete": result_id,
"date_op": date_op,
"quantite": quantite,
"prix_brut": prix_brut,
"frais_brocker": frais_brocker,
"frais_etat": frais_etat,
"frais_autre": frais_autre,
"ordre": ordre,
"type_ordre": type_ordre,
"etat": etat,
},
)
db.session.commit()
flash("Ordre créé avec succès !", "success")
except Exception as e:
db.session.rollback()
flash(f"Erreur lors de la création de l'ordre : {e}", "danger")
return redirect(url_for("main.gestion_ordres", annee=selected_annee))
# Route pour créer une nouvelle ligne rattachée à un ordre existant (depuis le bouton "Créer")
@main.route("/ordre/<int:ordre_id>/ajouter-ligne", methods=["GET", "POST"])
def ajouter_ligne_ordre(ordre_id):
if "user_id" not in session:
return redirect(url_for("main.index"))
# Récupérer l'en-tête de l'ordre, l'ISIN et le nom de la société associée
query_info = text("""
SELECT o.ID, o.isin, a.company_name
FROM ordre o
JOIN actions a ON o.isin = a.isin
WHERE o.ID = :ordre_id
""")
ordre_info = db.session.execute(query_info, {"ordre_id": ordre_id}).fetchone()
if not ordre_info:
flash("Ordre introuvable.", "danger")
return redirect(url_for("main.gestion_ordres"))
selected_annee = request.args.get("annee", "tout")
if request.method == "POST":
selected_annee = request.form.get("annee", "tout")
try:
quantite = float(request.form.get("quantite") or 0)
prix_brut = float(request.form.get("prix_brut") or 0)
frais_brocker = float(request.form.get("frais_brocker") or 0)
frais_etat = float(request.form.get("frais_etat") or 0)
frais_autre = float(request.form.get("frais_autre") or 0)
except ValueError:
flash("Veuillez saisir des valeurs numériques valides.", "danger")
return redirect(url_for("main.ajouter_ligne_ordre", ordre_id=ordre_id, annee=selected_annee))
try:
query_pied = text("""
INSERT INTO ordre_pied (Id_entete, Date_op, Quantite, Prix_brut, Frais_brocker, Frais_etat, Frais_autre, Ordre, Type, Etat)
VALUES (:id_entete, :date_op, :quantite, :prix_brut, :frais_brocker, :frais_etat, :frais_autre, :ordre, :type_ordre, :etat)
""")
db.session.execute(query_pied, {
"id_entete": ordre_id,
"date_op": request.form.get("date_op"),
"quantite": quantite,
"prix_brut": prix_brut,
"frais_brocker": frais_brocker,
"frais_etat": frais_etat,
"frais_autre": frais_autre,
"ordre": request.form.get("ordre"),
"type_ordre": request.form.get("type_ordre"),
"etat": request.form.get("etat"),
})
db.session.commit()
flash("Ligne ajoutée avec succès à l'ordre !", "success")
return redirect(url_for("main.gestion_ordres", annee=selected_annee))
except Exception as e:
db.session.rollback()
flash(f"Erreur lors de l'ajout : {e}", "danger")
contexte_form = {
"mode": "creer_ligne",
"isin": ordre_info.isin,
"company_name": ordre_info.company_name,
"action_url": url_for("main.ajouter_ligne_ordre", ordre_id=ordre_id)
}
return render_template("form_ordre_ligne.html", data=contexte_form, ligne=None)
# Route pour modifier une ligne spécifique (depuis le bouton "Modifier" de la ligne)
@main.route("/modifier-ligne-ordre/<int:pied_id>", methods=["GET", "POST"])
def modifier_ligne_ordre(pied_id):
if "user_id" not in session:
return redirect(url_for("main.index"))
# Récupérer la ligne du pied, l'ISIN et le nom de l'entreprise
query_get = text("""
SELECT op.*, o.isin, a.company_name
FROM ordre_pied op
JOIN ordre o ON op.Id_entete = o.ID
JOIN actions a ON o.isin = a.isin
WHERE op.ID = :pied_id
""")
ligne = db.session.execute(query_get, {"pied_id": pied_id}).fetchone()
if not ligne:
flash("Ligne d'ordre introuvable.", "danger")
return redirect(url_for("main.gestion_ordres"))
selected_annee = request.args.get("annee", "tout")
if request.method == "POST":
selected_annee = request.form.get("annee", "tout")
try:
quantite = float(request.form.get("quantite") or 0)
prix_brut = float(request.form.get("prix_brut") or 0)
frais_brocker = float(request.form.get("frais_brocker") or 0)
frais_etat = float(request.form.get("frais_etat") or 0)
frais_autre = float(request.form.get("frais_autre") or 0)
except ValueError:
flash("Veuillez saisir des valeurs numériques valides.", "danger")
return redirect(url_for("main.modifier_ligne_ordre", pied_id=pied_id, annee=selected_annee))
try:
query_update = text("""
UPDATE ordre_pied
SET Date_op = :date_op, Quantite = :quantite, Prix_brut = :prix_brut,
Frais_brocker = :frais_brocker, Frais_etat = :frais_etat, Frais_autre = :frais_autre,
Ordre = :ordre, Type = :type_ordre, Etat = :etat
WHERE ID = :pied_id
""")
db.session.execute(query_update, {
"date_op": request.form.get("date_op"),
"quantite": quantite,
"prix_brut": prix_brut,
"frais_brocker": frais_brocker,
"frais_etat": frais_etat,
"frais_autre": frais_autre,
"ordre": request.form.get("ordre"),
"type_ordre": request.form.get("type_ordre"),
"etat": request.form.get("etat"),
"pied_id": pied_id
})
db.session.commit()
flash("Ligne modifiée avec succès !", "success")
return redirect(url_for("main.gestion_ordres", annee=selected_annee))
except Exception as e:
db.session.rollback()
flash(f"Erreur lors de la modification : {e}", "danger")
contexte_form = {
"mode": "modifier_ligne",
"isin": ligne.isin,
"company_name": ligne.company_name,
"action_url": url_for("main.modifier_ligne_ordre", pied_id=pied_id)
}
return render_template("form_ordre_ligne.html", data=contexte_form, ligne=ligne)
+21
View File
@@ -0,0 +1,21 @@
from .actions import actions_bp
from .analyse import analyse_bp
from .auth import auth_bp
from .comptes import comptes_bp
from .historique import historique_bp
from .menu import menu_bp
from .ordres import ordres_bp
from .societes import societes_bp
from .utilisateurs import utilisateurs_bp
__all__ = [
"auth_bp",
"menu_bp",
"utilisateurs_bp",
"actions_bp",
"societes_bp",
"historique_bp",
"analyse_bp",
"ordres_bp",
"comptes_bp",
]
+98
View File
@@ -0,0 +1,98 @@
from flask import Blueprint, flash, redirect, render_template, request, url_for
from sqlalchemy import text
from app import db
from app.decorators import login_required
actions_bp = Blueprint("actions", __name__)
@actions_bp.route("/gestion-actions", methods=["GET", "POST"])
@login_required
def gestion_actions():
if request.method == "POST":
action_type = request.form.get("action")
action_id = request.form.get("id")
isin = request.form.get("isin") or None
ticker = request.form.get("ticker")
company_name = request.form.get("company_name")
exchange = request.form.get("exchange")
pays = request.form.get("pays")
currency = request.form.get("currency")
if action_type == "creer":
try:
query = text(
"INSERT INTO actions (isin, ticker, company_name, exchange, "
"pays, currency, updated_at) "
"VALUES (:isin, :ticker, :company_name, :exchange, :pays, "
":currency, NOW())"
)
db.session.execute(
query,
{
"isin": isin,
"ticker": ticker,
"company_name": company_name,
"exchange": exchange,
"pays": pays,
"currency": currency,
},
)
db.session.commit()
flash("Action créée avec succès", "success")
except Exception:
db.session.rollback()
flash("Erreur lors de la création de l'action.", "danger")
elif action_type == "modifier" and action_id:
try:
query = text(
"UPDATE actions SET isin = :isin, ticker = :ticker, "
"company_name = :company_name, exchange = :exchange, "
"pays = :pays, currency = :currency, updated_at = NOW() "
"WHERE id = :id"
)
db.session.execute(
query,
{
"id": action_id,
"isin": isin,
"ticker": ticker,
"company_name": company_name,
"exchange": exchange,
"pays": pays,
"currency": currency,
},
)
db.session.commit()
flash("Modification effectuée", "success")
except Exception:
db.session.rollback()
flash("Erreur lors de la modification.", "danger")
return redirect(url_for("actions.gestion_actions"))
actions_query = text(
"SELECT id, isin, ticker, company_name, exchange, pays, currency, "
"updated_at FROM actions ORDER BY company_name ASC"
)
result = db.session.execute(actions_query)
actions_list = [
{
"id": row.id,
"isin": row.isin if row.isin else "",
"ticker": row.ticker if row.ticker else "",
"company_name": row.company_name if row.company_name else "Sans nom",
"exchange": row.exchange if row.exchange else "",
"pays": row.pays if row.pays else "",
"currency": row.currency if row.currency else "",
"updated_at": row.updated_at.strftime("%Y-%m-%d %H:%M:%S")
if row.updated_at
else "",
}
for row in result
]
return render_template("gestion_actions.html", actions=actions_list)
+73
View File
@@ -0,0 +1,73 @@
from flask import Blueprint, jsonify, render_template, request
from app.decorators import login_required
analyse_bp = Blueprint("analyse", __name__)
@analyse_bp.route("/analyse", methods=["GET"])
@login_required
def analyse():
"""Page d'analyse technique. Si un ISIN est passé en paramètre (?isin=...),
on l'analyse et on affiche la fiche complète ; sinon on affiche la page
de recherche."""
isin = request.args.get("isin", "").strip().upper()
if not isin:
return render_template("analyse.html", resultat=None)
from app.services.analysis import analyse_action
print(f"[analyse] ISIN demandé : {isin}", flush=True)
resultat = analyse_action(isin)
if resultat is None:
# ISIN absent du référentiel actions
return render_template(
"analyse.html",
resultat={
"isin": isin,
"action": None,
"erreur": f"Aucune donnée trouvée pour l'ISIN {isin}. "
f"Vérifiez le code ou importez d'abord l'historique.",
},
isin_recherche=isin,
)
if resultat.get("erreur"):
# ISIN présent mais historique insuffisant
print(f"[analyse] {isin} : {resultat['erreur']}", flush=True)
else:
print(
f"[analyse] {isin} : cours={resultat.get('cours')} "
f"score={resultat.get('score')} tendance={resultat.get('tendance')}",
flush=True,
)
return render_template("analyse.html", resultat=resultat, isin_recherche=isin)
@analyse_bp.route("/analyse/<isin>", methods=["GET"])
@login_required
def analyse_isin(isin):
"""Page d'analyse technique complète pour un ISIN donné."""
from app.services.analysis import analyse_action
isin = isin.strip().upper()
resultat = analyse_action(isin)
return render_template("analyse.html", resultat=resultat, isin_recherche=isin)
@analyse_bp.route("/api/analyse/<isin>", methods=["GET"])
@login_required
def api_analyse(isin):
"""API JSON retournant l'analyse technique complète d'un ISIN."""
from app.services.analysis import analyse_action
isin = isin.strip().upper()
resultat = analyse_action(isin)
if resultat is None:
return jsonify({"erreur": f"ISIN {isin} introuvable dans le référentiel actions."}), 404
if resultat.get("erreur"):
return jsonify(resultat), 200
return jsonify(resultat)
+57
View File
@@ -0,0 +1,57 @@
from flask import Blueprint, flash, redirect, render_template, request, session, url_for
from sqlalchemy import text
from werkzeug.security import check_password_hash
from app import db
auth_bp = Blueprint("auth", __name__)
@auth_bp.route("/", methods=["GET", "POST"])
def index():
if "user_id" in session:
return redirect(url_for("menu.menu"))
if "login_attempts" not in session:
session["login_attempts"] = 0
if request.method == "POST":
user_login = request.form.get("login")
user_pass = request.form.get("pass")
query = text(
"SELECT id, pass AS user_pass, nom, prenom FROM login "
"WHERE login = :login AND actif = 1"
)
result = db.session.execute(query, {"login": user_login}).fetchone()
if result and check_password_hash(result.user_pass, user_pass):
# Régénération de session : protection contre la fixation de session
session.clear()
session["user_id"] = result.id
session["user_name"] = f"{result.prenom} {result.nom}"
session["login_attempts"] = 0
return redirect(url_for("menu.menu"))
else:
session["login_attempts"] += 1
essais = session["login_attempts"]
if essais >= 3:
session["login_attempts"] = 0
flash(
"Trop de tentatives. Réessayez dans quelques instants.",
"danger",
)
else:
flash(
f"Identifiant ou mot de passe incorrect. "
f"Il vous reste {3 - essais} essai(s).",
"danger",
)
return render_template("index.html")
@auth_bp.route("/logout")
def logout():
session.clear()
return redirect(url_for("auth.index"))
+365
View File
@@ -0,0 +1,365 @@
from flask import (
Blueprint,
current_app,
flash,
redirect,
render_template,
request,
session,
url_for,
)
from sqlalchemy import text
from app import db
from app.decorators import login_required
comptes_bp = Blueprint("comptes", __name__)
# ---------------------------------------------------------------------------
# Gestion des comptes
# ---------------------------------------------------------------------------
@comptes_bp.route("/gestion_comptes", methods=["GET", "POST"])
@login_required
def gestion_comptes():
from app.models import Societe, Compte
societes = Societe.query.order_by(Societe.nom.asc()).all()
# La société sélectionnée vient du formulaire (changement manuel),
# ou d'un paramètre d'URL (redirection après création/modification),
# ou de la session (mémorisation entre navigations).
selected_societe_id = (
request.form.get("societe_id")
or request.args.get("societe_id")
or session.get("selected_compte_societe_id")
)
if not selected_societe_id and societes:
selected_societe_id = societes[0].id
else:
try:
selected_societe_id = int(selected_societe_id) if selected_societe_id else None
except ValueError:
selected_societe_id = societes[0].id if societes else None
# Mémorisation en session : la société active ne change que manuellement
if selected_societe_id is not None:
session["selected_compte_societe_id"] = selected_societe_id
selected_societe_nom = ""
ecritures = []
total_credit = 0.0
total_debit = 0.0
# Agrégats financiers (voir règles de calcul dans la documentation)
tcredit = 0.0 # Depot : somme des crédits des écritures manuelles
tdebit = 0.0 # Retrait : somme des débits des écritures manuelles
engagement = 0.0 # ENGA. : positions actives (achat +, vente -)
pv = 0.0 # PV : positions soldées (achat -, vente +)
dispo = 0.0 # DISPO = TOTAL CREDIT - TOTAL DEBIT (toutes écritures)
solde = 0.0 # SOLDE = DISPO + ENGA.
if selected_societe_id:
societe_obj = Societe.query.get(selected_societe_id)
if societe_obj:
selected_societe_nom = societe_obj.nom
# Tri par date décroissante (du plus récent au plus ancien)
ecritures = (
Compte.query.filter_by(id_societe=selected_societe_id)
.order_by(Compte.date_operation.desc(), Compte.id.desc())
.all()
)
# Totaux bruts du tableau (toutes écritures confondues)
total_credit = sum(float(e.credit or 0) for e in ecritures)
total_debit = sum(float(e.debit or 0) for e in ecritures)
# --- Dépôts / Retraits : uniquement les écritures manuelles ---
for e in ecritures:
if e.source == "manuel":
tcredit += float(e.credit or 0)
tdebit += float(e.debit or 0)
# --- DISPO = TOTAL CREDIT - TOTAL DEBIT (toutes écritures) ---
dispo = total_credit - total_debit
# --- Engagement & PV : depuis les lignes d'ordres (ordre_pied) ---
# Règles :
# etat='actif' : achat -> engagement += qté*(prix_brut+frais)
# vente -> engagement -= qté*(prix_brut-frais)
# etat='soldé' : achat -> PV -= qté*(prix_brut+frais)
# vente -> PV += qté*(prix_brut-frais)
# etat='annulé' : ignoré (filtré en SQL)
# Filtres : société en cours + lignes non annulées
query_ordres = text(
"SELECT op.sens, op.etat, op.quantite, op.prix_brut, "
"op.frais_broker, op.frais_etat, op.frais_autre "
"FROM ordre o "
"JOIN ordre_pied op ON op.id_entete = o.id "
"WHERE o.id_societe = :soc_id "
"AND op.etat <> 'annulé'"
)
for row in db.session.execute(query_ordres, {"soc_id": selected_societe_id}):
quantite = float(row.quantite or 0)
prix_brut = float(row.prix_brut or 0)
frais_broker = float(row.frais_broker or 0)
frais_etat = float(row.frais_etat or 0)
frais_autre = float(row.frais_autre or 0)
# Achat : on paie le prix brut + les frais
montant_achat = quantite * (prix_brut + (prix_brut*(frais_broker + frais_etat + frais_autre)/100))
# Vente : on encaisse le prix brut - les frais
montant_vente = quantite * (prix_brut - (prix_brut*(frais_broker + frais_etat + frais_autre)/100))
sens = (row.sens or "").strip().lower()
etat = (row.etat or "").strip().lower()
if etat == "actif":
if sens == "achat":
engagement += montant_achat
elif sens == "vente":
engagement -= montant_vente
elif etat == "soldé" or etat == "solde":
if sens == "achat":
pv -= montant_achat
elif sens == "vente":
pv += montant_vente
# etat == 'annulé' : ne rien faire (déjà filtré en SQL)
solde = dispo + engagement
return render_template(
"gestion_comptes.html",
societes=societes,
selected_societe_id=selected_societe_id,
selected_societe=selected_societe_nom,
ecritures=ecritures,
total_credit=total_credit,
total_debit=total_debit,
tcredit=tcredit,
tdebit=tdebit,
engagement=engagement,
pv=pv,
dispo=dispo,
solde=solde,
)
# ---------------------------------------------------------------------------
# Génération automatique des écritures de compte depuis les ordres
# ---------------------------------------------------------------------------
@comptes_bp.route("/generer_ecritures_ordres", methods=["POST"])
@login_required
def generer_ecritures_ordres():
"""Génère les écritures comptables (table `compte`) à partir des ordres
et de leurs lignes (`ordre_pied`) pour la société actuellement sélectionnée.
Règles :
- achat : debit = quantite * (prix_brut + frais_broker + frais_etat + frais_autre), credit = 0
- vente : credit = quantite * (prix_brut + frais_broker + frais_etat + frais_autre), debit = 0
- libelle= "Achat/Vente actions {isin} {company_name}"
- source = 'ordre' (non modifiable depuis l'IHM)
Idempotent : supprime d'abord les écritures source='ordre' de la société.
"""
id_societe = request.form.get("id_societe") or session.get(
"selected_compte_societe_id"
)
if not id_societe:
flash("Aucune société sélectionnée pour la génération.", "danger")
return redirect(url_for("comptes.gestion_comptes"))
try:
id_societe_int = int(id_societe)
except (TypeError, ValueError):
flash("Identifiant de société invalide.", "danger")
return redirect(url_for("comptes.gestion_comptes"))
try:
# 1. Suppression des anciennes écritures générées pour cette société
db.session.execute(
text(
"DELETE FROM compte WHERE id_societe = :soc_id AND source = 'ordre'"
),
{"soc_id": id_societe_int},
)
# 2. Parcours des ordres de la société, puis de leurs lignes
# Filtres : société en cours + lignes non annulées
query = text(
"SELECT o.id, o.isin, a.company_name, "
"op.date_op, op.quantite, op.prix_brut, "
"op.frais_broker, op.frais_etat, op.frais_autre, op.sens "
"FROM ordre o "
"JOIN ordre_pied op ON op.id_entete = o.id "
"JOIN actions a ON o.isin = a.isin "
"WHERE o.id_societe = :soc_id "
"AND op.etat <> 'annulé' "
"ORDER BY o.id, op.date_op"
)
lignes = db.session.execute(query, {"soc_id": id_societe_int}).fetchall()
insert = text(
"INSERT INTO compte (id_societe, date_operation, debit, credit, "
"libelle, source) "
"VALUES (:id_societe, :date_op, :debit, :credit, :libelle, 'ordre')"
)
count = 0
from markupsafe import escape
for row in lignes:
quantite = float(row.quantite or 0)
prix_brut = float(row.prix_brut or 0)
frais_broker = float(row.frais_broker or 0)
frais_etat = float(row.frais_etat or 0)
frais_autre = float(row.frais_autre or 0)
sens = (row.sens or "").strip().lower()
isin = row.isin or ""
# Nom de l'action échappé puis mis en gras (affichage |safe côté template)
company = f"<strong>{escape(row.company_name or '')}</strong>"
if sens == "achat":
# Achat : on paie le prix brut + les frais
montant = quantite * (prix_brut + (prix_brut * (frais_broker + frais_etat + frais_autre) / 100))
debit = montant
credit = 0.0
libelle = f"Achat actions {isin} {company}".strip()
elif sens == "vente":
# Vente : on encaisse le prix brut - les frais
montant = quantite * (prix_brut - (prix_brut * (frais_broker + frais_etat + frais_autre) / 100))
debit = 0.0
credit = montant
libelle = f"Vente actions {isin} {company}".strip()
else:
# Sens inattendu : on ignore la ligne
continue
db.session.execute(
insert,
{
"id_societe": id_societe_int,
"date_op": row.date_op,
"debit": debit,
"credit": credit,
"libelle": libelle,
},
)
count += 1
db.session.commit()
flash(
f"Génération terminée : {count} écriture(s) créée(s) pour "
f"la société.",
"success",
)
except Exception as e:
db.session.rollback()
current_app.logger.error("Erreur génération écritures : %s", e, exc_info=True)
flash("Erreur lors de la génération des écritures.", "danger")
return redirect(url_for("comptes.gestion_comptes", societe_id=id_societe_int))
# ---------------------------------------------------------------------------
# Création d'une écriture comptable
# ---------------------------------------------------------------------------
@comptes_bp.route("/creer_compte_traitement", methods=["POST"])
@login_required
def creer_compte_traitement():
id_societe = request.form.get("id_societe")
date_operation = request.form.get("date_operation")
libelle = request.form.get("libelle", "").strip()
debit = request.form.get("debit", "0")
credit = request.form.get("credit", "0")
if not id_societe or not date_operation:
flash("Veuillez remplir tous les champs obligatoires.", "danger")
return redirect(url_for("comptes.gestion_comptes", societe_id=id_societe or ""))
try:
debit_val = float(debit) if debit else 0.0
credit_val = float(credit) if credit else 0.0
id_societe_int = int(id_societe)
except ValueError:
flash("Veuillez saisir des valeurs numériques valides.", "danger")
return redirect(url_for("comptes.gestion_comptes", societe_id=id_societe or ""))
try:
db.session.execute(
text(
"INSERT INTO compte (id_societe, date_operation, libelle, "
"debit, credit) VALUES (:id_societe, :date_op, :libelle, :debit, :credit)"
),
{
"id_societe": id_societe_int,
"date_op": date_operation,
"libelle": libelle or None,
"debit": debit_val,
"credit": credit_val,
},
)
db.session.commit()
flash("Écriture créée avec succès !", "success")
except Exception as e:
db.session.rollback()
current_app.logger.error("Erreur création écriture : %s", e, exc_info=True)
flash("Erreur lors de la création de l'écriture.", "danger")
# On reste sur la même société après validation
return redirect(url_for("comptes.gestion_comptes", societe_id=id_societe_int))
# ---------------------------------------------------------------------------
# Modification d'une écriture comptable
# ---------------------------------------------------------------------------
@comptes_bp.route("/modifier_compte/<int:id>", methods=["POST"])
@login_required
def modifier_compte(id):
date_operation = request.form.get("date_operation")
libelle = request.form.get("libelle", "").strip()
debit = request.form.get("debit", "0")
credit = request.form.get("credit", "0")
# Récupération de la société de l'écriture avant modification (pour la redirection)
id_societe = request.form.get("id_societe")
if not id_societe:
row = db.session.execute(
text("SELECT id_societe FROM compte WHERE id = :id"), {"id": id}
).fetchone()
id_societe = str(row.id_societe) if row else ""
if not date_operation:
flash("Veuillez remplir tous les champs obligatoires.", "danger")
return redirect(url_for("comptes.gestion_comptes", societe_id=id_societe))
try:
debit_val = float(debit) if debit else 0.0
credit_val = float(credit) if credit else 0.0
except ValueError:
flash("Veuillez saisir des valeurs numériques valides.", "danger")
return redirect(url_for("comptes.gestion_comptes", societe_id=id_societe))
try:
db.session.execute(
text(
"UPDATE compte SET date_operation = :date_op, "
"libelle = :libelle, debit = :debit, credit = :credit WHERE id = :id"
),
{
"date_op": date_operation,
"libelle": libelle or None,
"debit": debit_val,
"credit": credit_val,
"id": id,
},
)
db.session.commit()
flash("Écriture modifiée avec succès !", "success")
except Exception as e:
db.session.rollback()
current_app.logger.error("Erreur modification écriture : %s", e, exc_info=True)
flash("Erreur lors de la modification de l'écriture.", "danger")
# On reste sur la même société après validation
return redirect(url_for("comptes.gestion_comptes", societe_id=id_societe))
+381
View File
@@ -0,0 +1,381 @@
import csv
import io
from datetime import datetime
from flask import (
Blueprint,
Response,
current_app,
flash,
redirect,
render_template,
request,
url_for,
)
from sqlalchemy import text
from app import db
from app.decorators import login_required
historique_bp = Blueprint("historique", __name__)
# ---------------------------------------------------------------------------
# Import / Export des actions CSV
# ---------------------------------------------------------------------------
@historique_bp.route("/gestion-import-export-actions-csv", methods=["GET"])
@login_required
def gestion_import_export_actions_csv():
return render_template("gestion_import_export_actions_csv.html")
@historique_bp.route("/export-actions-csv", methods=["GET"])
@login_required
def export_actions_csv():
try:
query = text(
"SELECT id, isin, ticker, company_name, exchange, pays, currency, "
"updated_at FROM actions"
)
result = db.session.execute(query)
output = io.StringIO()
writer = csv.writer(output, delimiter=";", quoting=csv.QUOTE_MINIMAL)
writer.writerow(
["id", "isin", "ticker", "company_name", "exchange", "pays", "currency", "updated_at"]
)
for row in result:
writer.writerow(
[
row.id,
row.isin or "",
row.ticker or "",
row.company_name or "",
row.exchange or "",
row.pays or "",
row.currency or "",
row.updated_at.strftime("%Y-%m-%d %H:%M:%S")
if row.updated_at
else "",
]
)
output.seek(0)
return Response(
output.getvalue(),
mimetype="text/csv",
headers={"Content-Disposition": "attachment;filename=actions_export.csv"},
)
except Exception as e:
current_app.logger.error("Erreur export CSV : %s", e, exc_info=True)
flash("Erreur lors de l'exportation du fichier CSV.", "danger")
return redirect(url_for("historique.gestion_import_export_actions_csv"))
@historique_bp.route("/import-actions-csv", methods=["POST"])
@login_required
def import_actions_csv():
if "file" not in request.files:
flash("Aucun fichier sélectionné.", "danger")
return redirect(url_for("historique.gestion_import_export_actions_csv"))
file = request.files["file"]
if file.filename == "":
flash("Aucun fichier sélectionné.", "danger")
return redirect(url_for("historique.gestion_import_export_actions_csv"))
if not file.filename.endswith(".csv"):
flash("Veuillez fournir un fichier au format .csv valide.", "warning")
return redirect(url_for("historique.gestion_import_export_actions_csv"))
try:
stream = io.TextIOWrapper(file.stream, encoding="utf-8")
csv_reader = csv.reader(stream, delimiter=";")
header = next(csv_reader, None)
if not header:
flash("Le fichier CSV est vide.", "danger")
return redirect(url_for("historique.gestion_import_export_actions_csv"))
count = 0
for row in csv_reader:
if len(row) >= 6:
isin = row[0].strip() if row[0] != "" else None
ticker = row[1].strip() if row[1] != "" else None
company_name = row[2].strip() if row[2] != "" else None
exchange = row[3].strip() if row[3] != "" else ""
pays = row[4].strip() if row[4] != "" else ""
currency = row[5].strip() if row[5] != "" else ""
if not isin or not ticker:
continue
query = text(
"INSERT INTO actions (isin, ticker, company_name, exchange, "
"pays, currency, updated_at) "
"VALUES (:isin, :ticker, :company_name, :exchange, :pays, "
":currency, NOW()) "
"ON DUPLICATE KEY UPDATE ticker = :ticker, "
"company_name = :company_name, exchange = :exchange, "
"pays = :pays, currency = :currency, updated_at = NOW()"
)
db.session.execute(
query,
{
"isin": isin,
"ticker": ticker,
"company_name": company_name,
"exchange": exchange,
"pays": pays,
"currency": currency,
},
)
count += 1
db.session.commit()
flash(f"Importation réussie : {count} actions traitées.", "success")
except Exception as e:
db.session.rollback()
current_app.logger.error("Erreur import CSV : %s", e, exc_info=True)
flash(f"Erreur lors de l'importation du fichier : {str(e)}", "danger")
return redirect(url_for("historique.gestion_import_export_actions_csv"))
# ---------------------------------------------------------------------------
# Import historique CSV (cotation d'une action)
# ---------------------------------------------------------------------------
@historique_bp.route("/gestion-import-historique-csv", methods=["GET"])
@login_required
def gestion_import_historique_csv():
return render_template("gestion_import_historique_csv.html")
def _convertir_vol(valeur):
"""Convertit une valeur de volume du CSV en entier.
Suffixes gérés : 'K' (x1000), 'M' (x1 000 000). Sinon entier brut.
"""
if valeur is None:
return None
s = str(valeur).strip()
if s == "" or s.upper() == "N/A" or s.upper() == "NA":
return None
try:
if s.upper().endswith("K"):
return int(float(s[:-1]) * 1000)
if s.upper().endswith("M"):
return int(float(s[:-1]) * 1000000)
return int(float(s))
except (TypeError, ValueError):
return None
def _to_decimal(valeur):
"""Convertit une valeur CSV en float (Decimal), None si vide/invalide.
Retire le suffixe '%' (ex: '0.7%' -> 0.7) et les séparateurs de milliers."""
if valeur is None:
return None
s = str(valeur).strip()
if s == "" or s.upper() in ("N/A", "NA"):
return None
# Suppression d'un éventuel suffixe '%' (et espaces autour)
if s.endswith("%"):
s = s[:-1].strip()
# Suppression d'un éventuel séparateur de milliers (virgule ou espace)
s = s.replace(" ", "").replace(",", "")
try:
return float(s)
except (TypeError, ValueError):
return None
@historique_bp.route("/import-historique-csv", methods=["POST"])
@login_required
def import_historique_csv():
isin = (request.form.get("isin") or "").strip().upper()
# Vérification que l'ISIN existe bien dans le référentiel actions
if not isin:
flash("Veuillez saisir un code ISIN.", "danger")
return redirect(url_for("historique.gestion_import_historique_csv"))
action = db.session.execute(
text("SELECT company_name FROM actions WHERE isin = :isin"), {"isin": isin}
).fetchone()
if not action:
flash(f"L'ISIN {isin} n'existe pas dans le référentiel actions.", "danger")
return redirect(url_for("historique.gestion_import_historique_csv"))
if "file" not in request.files:
flash("Aucun fichier sélectionné.", "danger")
return redirect(url_for("historique.gestion_import_historique_csv"))
file = request.files["file"]
if file.filename == "":
flash("Aucun fichier sélectionné.", "danger")
return redirect(url_for("historique.gestion_import_historique_csv"))
if not file.filename.lower().endswith(".csv"):
flash("Veuillez fournir un fichier au format .csv valide.", "warning")
return redirect(url_for("historique.gestion_import_historique_csv"))
try:
# Lecture du fichier brut avec fallback d'encodage (utf-8 puis latin-1)
raw_bytes = file.stream.read()
# Suppression d'un éventuel BOM UTF-8 (\xef\xbb\xbf) en début de fichier
if raw_bytes.startswith(b"\xef\xbb\xbf"):
raw_bytes = raw_bytes[3:]
try:
content = raw_bytes.decode("utf-8")
except UnicodeDecodeError:
content = raw_bytes.decode("latin-1")
stream = io.StringIO(content)
# Auto-détection du séparateur (, ; ou tabulation) sur la 1re ligne
premiere_ligne = stream.readline()
stream.seek(0)
for delim in (",", ";", "\t"):
nb = len(premiere_ligne.split(delim))
if nb > 1:
separateur = delim
break
else:
separateur = ","
csv_reader = csv.reader(stream, delimiter=separateur)
header = next(csv_reader, None)
if not header:
flash("Le fichier CSV est vide.", "danger")
return redirect(url_for("historique.gestion_import_historique_csv"))
# Normalisation des en-têtes : strip + suppression des quotes + lower
def _norm_header(h):
h = h.strip()
if len(h) >= 2 and h[0] in ('"', "'") and h[-1] == h[0]:
h = h[1:-1]
return h.strip().lower()
header_norm = [_norm_header(h) for h in header]
# Mapping alias -> nom canonique (couvre les variantes EODHD, Yahoo, etc.)
alias = {
"date": "date",
"price": "price", "close": "price", "last": "price",
"adj_close": "price", "adjusted_close": "price", "adj. close": "price",
"open": "open",
"hight": "hight", "high": "hight",
"low": "low",
"vol": "vol", "vol.": "vol", "volume": "vol",
"change": "change", "change %": "change", "change_pct": "change",
"pct_change": "change", "change_percent": "change", "chg": "change",
}
# Construction du dictionnaire nom_canonique -> index
idx = {}
for i, h in enumerate(header_norm):
canon = alias.get(h)
if canon and canon not in idx:
idx[canon] = i
def col(row, name):
"""Récupère la valeur d'une colonne par son nom canonique."""
if name not in idx:
return None
i = idx[name]
return row[i] if i < len(row) else None
# Vérification : la colonne 'date' est obligatoire
if "date" not in idx:
flash(
f"Pas OK - Colonne 'date' introuvable dans l'en-tête CSV. "
f"En-tête détecté : {','.join(header)}",
"danger",
)
return redirect(url_for("historique.gestion_import_historique_csv"))
# Requête d'insertion (INSERT IGNORE grâce à l'unique key isin+date)
insert = text(
"INSERT IGNORE INTO historique "
"(isin, date, price, `open`, hight, low, vol, `change`) "
"VALUES (:isin, :date, :price, :open, :hight, :low, :vol, :change)"
)
count_insere = 0
count_ignore = 0
count_erreurs = 0
exemples_erreurs = []
for row in csv_reader:
date_raw = col(row, "date")
if not date_raw:
count_erreurs += 1
if len(exemples_erreurs) < 5:
exemples_erreurs.append(f"Date vide | ligne: {row}")
continue
# Normalisation de la date -> objet date (pour insertion YYYY-MM-DD)
# L'ordre compte : %Y/%d/%m (AAAA/JJ/MM) AVANT %Y/%m/%d pour éviter
# l'ambiguïté (un jour >12 invaliderait %Y/%m/%d et tomberait juste sur %Y/%d/%m).
date_str = date_raw.strip()[:10]
date_val = None
for fmt in ("%Y-%m-%d", "%Y/%d/%m", "%Y/%m/%d", "%m/%d/%Y", "%d/%m/%Y", "%d-%m-%Y"):
try:
date_val = datetime.strptime(date_str, fmt).date()
break
except ValueError:
continue
if date_val is None:
count_erreurs += 1
if len(exemples_erreurs) < 10:
exemples_erreurs.append(
f"Date invalide '{date_raw}' (nb colonnes ligne={len(row)}) | ligne: {row}"
)
continue
price = _to_decimal(col(row, "price"))
open_ = _to_decimal(col(row, "open"))
hight = _to_decimal(col(row, "hight"))
low = _to_decimal(col(row, "low"))
vol = _convertir_vol(col(row, "vol"))
change = _to_decimal(col(row, "change"))
result = db.session.execute(
insert,
{
"isin": isin,
"date": date_val,
"price": price,
"open": open_,
"hight": hight,
"low": low,
"vol": vol,
"change": change,
},
)
# rowcount == 1 si réellement inséré, 0 si ignoré (doublon unique key)
if result.rowcount == 1:
count_insere += 1
else:
count_ignore += 1
db.session.commit()
detail = ""
if exemples_erreurs:
detail = " | Exemples d'erreurs: " + " ; ".join(exemples_erreurs)
flash(
f"OK - Importation réussie pour {action.company_name} ({isin}) : "
f"{count_insere} ligne(s) insérée(s), "
f"{count_ignore} ligne(s) déjà existante(s) ignorée(s), "
f"{count_erreurs} ligne(s) en erreur. "
f"[Séparateur: '{separateur}' | En-tête: {','.join(header)}]"
f"{detail}",
"success",
)
except Exception as e:
db.session.rollback()
current_app.logger.error("Erreur import historique CSV : %s", e, exc_info=True)
flash(f"Pas OK - Erreur lors de l'importation du fichier : {str(e)}", "danger")
return redirect(url_for("historique.gestion_import_historique_csv"))
+11
View File
@@ -0,0 +1,11 @@
from flask import Blueprint, render_template
from app.decorators import login_required
menu_bp = Blueprint("menu", __name__)
@menu_bp.route("/menu")
@login_required
def menu():
return render_template("menu.html")
+476
View File
@@ -0,0 +1,476 @@
from flask import (
Blueprint,
current_app,
flash,
jsonify,
redirect,
render_template,
request,
session,
url_for,
)
from sqlalchemy import text
from app import csrf, db
from app.decorators import login_required
ordres_bp = Blueprint("ordres", __name__)
# ---------------------------------------------------------------------------
# Gestion des ordres
# ---------------------------------------------------------------------------
@ordres_bp.route("/gestion_ordres", methods=["GET", "POST"])
@login_required
def gestion_ordres():
selected_societe_id = (
request.form.get("societe_id")
or request.args.get("societe_id")
or session.get("selected_societe_id")
)
selected_annee = (
request.form.get("annee")
or request.args.get("annee")
or session.get("selected_annee", "Tout")
)
session["selected_annee"] = selected_annee
societes = []
selected_societe = ""
groupes_dict = {}
total_plus_value_globale = 0.0
with db.engine.connect() as connection:
result_soc = connection.execute(
text("SELECT id, nom FROM societe ORDER BY nom ASC")
)
societes = [dict(row._mapping) for row in result_soc]
if societes:
if not selected_societe_id:
selected_societe_id = societes[0]["id"]
else:
try:
selected_societe_id = int(selected_societe_id)
except (TypeError, ValueError):
selected_societe_id = societes[0]["id"]
session["selected_societe_id"] = selected_societe_id
for soc in societes:
if soc["id"] == selected_societe_id:
selected_societe = soc["nom"]
break
# --- CALCUL DE LA PLUS-VALUE TOTALE ---
annee_str = str(selected_annee).strip().lower()
if not annee_str or annee_str == "tout":
query_pv = text(
"SELECT SUM(plus_value) AS total_pv FROM ordre "
"WHERE id_societe = :soc_id AND plus_value IS NOT NULL"
)
res_pv = connection.execute(
query_pv, {"soc_id": selected_societe_id}
).fetchone()
else:
query_pv = text(
"SELECT SUM(plus_value) AS total_pv FROM ordre "
"WHERE id_societe = :soc_id AND plus_value IS NOT NULL "
"AND LEFT(CAST(date_plus_value AS CHAR), 4) = :annee"
)
res_pv = connection.execute(
query_pv, {"soc_id": selected_societe_id, "annee": annee_str}
).fetchone()
if res_pv and res_pv.total_pv is not None:
total_plus_value_globale = float(res_pv.total_pv)
# --- CHARGEMENT DES ORDRES ---
query = text(
"SELECT o.id AS ordre_id, o.isin, o.plus_value, o.date_plus_value, "
"op.id AS pied_id, op.date_op, op.quantite, op.prix_brut, "
"op.frais_broker, op.frais_etat, op.frais_autre, "
"op.sens, op.position, op.etat, "
"a.ticker, a.company_name, a.currency "
"FROM ordre o "
"JOIN ordre_pied op ON o.id = op.id_entete "
"JOIN actions a ON o.isin = a.isin "
"WHERE o.id_societe = :soc_id "
"ORDER BY o.id DESC, op.date_op DESC"
)
result_ordres = connection.execute(
query, {"soc_id": selected_societe_id}
)
for row in result_ordres:
r = dict(row._mapping)
ordre_id = r["ordre_id"]
isin = r["isin"]
quantite = float(r["quantite"]) if r["quantite"] is not None else 0.0
prix_brut = float(r["prix_brut"]) if r["prix_brut"] is not None else 0.0
frais_broker = (
float(r["frais_broker"]) if r["frais_broker"] is not None else 0.0
)
frais_etat = (
float(r["frais_etat"]) if r["frais_etat"] is not None else 0.0
)
frais_autre = (
float(r["frais_autre"]) if r["frais_autre"] is not None else 0.0
)
frais_total = frais_broker + frais_etat + frais_autre
sens_ordre = str(r["sens"]).strip().lower()
type_ordre = str(r["position"]).strip().lower() if r["position"] else "long"
# Prix Net unitaire
if sens_ordre == "vente":
prix_net = prix_brut - (prix_brut * frais_total / 100)
else:
prix_net = prix_brut + (prix_brut * frais_total / 100)
# Engagements ligne
eng_brut = quantite * prix_brut
eng_net = quantite * prix_net
pied_data = {
"id": r["pied_id"],
"date_op": r["date_op"],
"quantite": quantite,
"prix_brut": prix_brut,
"frais": frais_total,
"frais_broker": frais_broker,
"frais_etat": frais_etat,
"frais_autre": frais_autre,
"prix_net": prix_net,
"eng_brut": eng_brut,
"eng_net": eng_net,
"ordre": r["sens"],
"type_ordre": r["position"],
"etat": r["etat"],
}
if ordre_id not in groupes_dict:
groupes_dict[ordre_id] = {
"id": ordre_id,
"isin": isin,
"company_name": r["company_name"],
"ticker": r["ticker"],
"pieds": [],
"total_quantite": 0.0,
"total_prix_brut": 0.0,
"total_frais": 0.0,
"total_prix_net": 0.0,
"total_eng_brut": 0.0,
"total_eng_net": 0.0,
"plus_value": r["plus_value"],
"date_plusvalue": r["date_plus_value"],
}
groupes_dict[ordre_id]["pieds"].append(pied_data)
groupes_dict[ordre_id]["total_prix_brut"] += prix_brut
groupes_dict[ordre_id]["total_frais"] += frais_total
groupes_dict[ordre_id]["total_prix_net"] += prix_net
# Règles de totaux
if sens_ordre == "achat":
groupes_dict[ordre_id]["total_quantite"] += quantite
groupes_dict[ordre_id]["total_eng_brut"] -= eng_brut
groupes_dict[ordre_id]["total_eng_net"] -= eng_net
elif sens_ordre == "vente":
groupes_dict[ordre_id]["total_quantite"] -= quantite
groupes_dict[ordre_id]["total_eng_brut"] += eng_brut
groupes_dict[ordre_id]["total_eng_net"] += eng_net
# --- MISE À JOUR AUTO DE LA PLUS-VALUE (ordres soldés) ---
try:
with db.engine.begin() as conn:
for ordre_id, groupe in groupes_dict.items():
if abs(groupe["total_quantite"]) < 1e-9:
total_eng_net = groupe["total_eng_net"]
max_date = max(
(p["date_op"] for p in groupe["pieds"] if p["date_op"] is not None),
default=None,
)
conn.execute(
text(
"UPDATE ordre_pied SET etat = :nouvel_etat "
"WHERE id_entete = :ordre_id"
),
{"nouvel_etat": "soldé", "ordre_id": ordre_id},
)
conn.execute(
text(
"UPDATE ordre SET plus_value = :plus_value, "
"date_plus_value = :date_pv WHERE id = :ordre_id"
),
{
"plus_value": total_eng_net,
"date_pv": max_date,
"ordre_id": ordre_id,
},
)
groupe["plus_value"] = total_eng_net
groupe["date_plusvalue"] = max_date
for pied in groupe["pieds"]:
pied["etat"] = "soldé"
except Exception as e:
current_app.logger.error("Erreur MAJ plus-value : %s", e, exc_info=True)
groupes_ordres = list(groupes_dict.values())
annees_combo = ["Tout"] + [str(an) for an in range(2020, 2041)]
return render_template(
"gestion_ordres.html",
societes=societes,
selected_societe_id=selected_societe_id,
selected_societe=selected_societe,
groupes_ordres=groupes_ordres,
total_plus_value_globale=total_plus_value_globale,
annees_combo=annees_combo,
selected_annee=selected_annee,
)
# ---------------------------------------------------------------------------
# Création d'ordre (la saisie se fait via la modale de gestion_ordres ;
# cette route est conservée pour compatibilité et redirige vers la liste)
# ---------------------------------------------------------------------------
@ordres_bp.route("/creer-ordre", methods=["GET", "POST"])
@login_required
def creer_ordre():
return redirect(url_for("ordres.gestion_ordres"))
# ---------------------------------------------------------------------------
# API : vérifier l'existence d'un ISIN (JSON, exemptée de CSRF)
# ---------------------------------------------------------------------------
@ordres_bp.route("/api/verifier_isin", methods=["POST"])
@csrf.exempt
def verifier_isin():
data = request.get_json(silent=True) or {}
isin = (data.get("isin") or "").strip()
query = text(
"SELECT company_name, ticker, currency FROM actions WHERE isin = :isin"
)
action = db.session.execute(query, {"isin": isin}).fetchone()
if action:
return jsonify(
{
"exists": True,
"company_name": action.company_name,
"ticker": action.ticker,
"currency": action.currency,
}
)
return jsonify({"exists": False})
# ---------------------------------------------------------------------------
# Traitement de la création d'un ordre (appelé par la modale)
# ---------------------------------------------------------------------------
@ordres_bp.route("/creer-ordre-traitement", methods=["POST"])
@login_required
def creer_ordre_traitement():
isin = request.form.get("isin")
date_op = request.form.get("date_op")
selected_annee = request.form.get("annee", "tout")
try:
quantite = float(request.form.get("quantite") or 0)
prix_brut = float(request.form.get("prix_brut") or 0)
frais_broker = float(request.form.get("frais_broker") or 0)
frais_etat = float(request.form.get("frais_etat") or 0)
frais_autre = float(request.form.get("frais_autre") or 0)
except ValueError:
flash(
"Veuillez saisir des valeurs numériques valides pour les quantités, "
"prix et frais.",
"danger",
)
return redirect(url_for("ordres.gestion_ordres", annee=selected_annee))
sens = request.form.get("ordre")
position = request.form.get("type_ordre")
etat = request.form.get("etat")
id_societe = session.get("selected_societe_id")
if not id_societe:
flash(
"Veuillez sélectionner une société active avant de créer un ordre.",
"danger",
)
return redirect(url_for("ordres.gestion_ordres", annee=selected_annee))
try:
query_ordre = text(
"INSERT INTO ordre (id_societe, isin) VALUES (:id_societe, :isin)"
)
db.session.execute(query_ordre, {"id_societe": id_societe, "isin": isin})
result_id = db.session.execute(text("SELECT LAST_INSERT_ID()")).scalar()
if not result_id:
result_id = db.session.execute(
text(
"SELECT id FROM ordre WHERE isin = :isin "
"ORDER BY id DESC LIMIT 1"
),
{"isin": isin},
).scalar()
query_pied = text(
"INSERT INTO ordre_pied (id_entete, date_op, quantite, prix_brut, "
"frais_broker, frais_etat, frais_autre, sens, position, etat) "
"VALUES (:id_entete, :date_op, :quantite, :prix_brut, :frais_broker, "
":frais_etat, :frais_autre, :sens, :position, :etat)"
)
db.session.execute(
query_pied,
{
"id_entete": result_id,
"date_op": date_op,
"quantite": quantite,
"prix_brut": prix_brut,
"frais_broker": frais_broker,
"frais_etat": frais_etat,
"frais_autre": frais_autre,
"sens": sens,
"position": position,
"etat": etat,
},
)
db.session.commit()
flash("Ordre créé avec succès !", "success")
except Exception as e:
db.session.rollback()
current_app.logger.error("Erreur création ordre : %s", e, exc_info=True)
flash("Erreur lors de la création de l'ordre.", "danger")
return redirect(url_for("ordres.gestion_ordres", annee=selected_annee))
# ---------------------------------------------------------------------------
# Ajout d'une ligne à un ordre existant (POST depuis la modale)
# ---------------------------------------------------------------------------
@ordres_bp.route("/ordre/<int:ordre_id>/ajouter-ligne", methods=["GET", "POST"])
@login_required
def ajouter_ligne_ordre(ordre_id):
selected_annee = request.args.get("annee", "tout")
if request.method == "POST":
selected_annee = request.form.get("annee", "tout")
try:
quantite = float(request.form.get("quantite") or 0)
prix_brut = float(request.form.get("prix_brut") or 0)
frais_broker = float(request.form.get("frais_broker") or 0)
frais_etat = float(request.form.get("frais_etat") or 0)
frais_autre = float(request.form.get("frais_autre") or 0)
except ValueError:
flash("Veuillez saisir des valeurs numériques valides.", "danger")
return redirect(
url_for(
"ordres.ajouter_ligne_ordre",
ordre_id=ordre_id,
annee=selected_annee,
)
)
try:
query_pied = text(
"INSERT INTO ordre_pied (id_entete, date_op, quantite, prix_brut, "
"frais_broker, frais_etat, frais_autre, sens, position, etat) "
"VALUES (:id_entete, :date_op, :quantite, :prix_brut, :frais_broker, "
":frais_etat, :frais_autre, :sens, :position, :etat)"
)
db.session.execute(
query_pied,
{
"id_entete": ordre_id,
"date_op": request.form.get("date_op"),
"quantite": quantite,
"prix_brut": prix_brut,
"frais_broker": frais_broker,
"frais_etat": frais_etat,
"frais_autre": frais_autre,
"sens": request.form.get("ordre"),
"position": request.form.get("type_ordre"),
"etat": request.form.get("etat"),
},
)
db.session.commit()
flash("Ligne ajoutée avec succès à l'ordre !", "success")
return redirect(url_for("ordres.gestion_ordres", annee=selected_annee))
except Exception as e:
db.session.rollback()
current_app.logger.error("Erreur ajout ligne : %s", e, exc_info=True)
flash("Erreur lors de l'ajout de la ligne.", "danger")
return redirect(url_for("ordres.gestion_ordres", annee=selected_annee))
# ---------------------------------------------------------------------------
# Modification d'une ligne d'ordre (POST depuis la modale)
# ---------------------------------------------------------------------------
@ordres_bp.route("/modifier-ligne-ordre/<int:pied_id>", methods=["GET", "POST"])
@login_required
def modifier_ligne_ordre(pied_id):
selected_annee = request.args.get("annee", "tout")
if request.method == "POST":
selected_annee = request.form.get("annee", "tout")
try:
quantite = float(request.form.get("quantite") or 0)
prix_brut = float(request.form.get("prix_brut") or 0)
frais_broker = float(request.form.get("frais_broker") or 0)
frais_etat = float(request.form.get("frais_etat") or 0)
frais_autre = float(request.form.get("frais_autre") or 0)
except ValueError:
flash("Veuillez saisir des valeurs numériques valides.", "danger")
return redirect(
url_for(
"ordres.modifier_ligne_ordre",
pied_id=pied_id,
annee=selected_annee,
)
)
try:
query_update = text(
"UPDATE ordre_pied SET date_op = :date_op, quantite = :quantite, "
"prix_brut = :prix_brut, frais_broker = :frais_broker, "
"frais_etat = :frais_etat, frais_autre = :frais_autre, "
"sens = :sens, position = :position, etat = :etat "
"WHERE id = :pied_id"
)
db.session.execute(
query_update,
{
"date_op": request.form.get("date_op"),
"quantite": quantite,
"prix_brut": prix_brut,
"frais_broker": frais_broker,
"frais_etat": frais_etat,
"frais_autre": frais_autre,
"sens": request.form.get("ordre"),
"position": request.form.get("type_ordre"),
"etat": request.form.get("etat"),
"pied_id": pied_id,
},
)
db.session.commit()
flash("Ligne modifiée avec succès !", "success")
return redirect(url_for("ordres.gestion_ordres", annee=selected_annee))
except Exception as e:
db.session.rollback()
current_app.logger.error("Erreur modification ligne : %s", e, exc_info=True)
flash("Erreur lors de la modification de la ligne.", "danger")
return redirect(url_for("ordres.gestion_ordres", annee=selected_annee))
+118
View File
@@ -0,0 +1,118 @@
from flask import Blueprint, current_app, flash, redirect, render_template, request, url_for
from sqlalchemy import text
from app import db
from app.decorators import login_required
societes_bp = Blueprint("societes", __name__)
@societes_bp.route("/gestion-societes", methods=["GET", "POST"])
@login_required
def gestion_societes():
if request.method == "POST":
action = request.form.get("action")
soc_id = request.form.get("id")
nom = request.form.get("nom")
forme = request.form.get("forme")
capital = request.form.get("capital")
siren = request.form.get("siren")
siret = request.form.get("siret")
rcs = request.form.get("rcs")
tva = request.form.get("tva")
naf = request.form.get("naf")
tel = request.form.get("tel")
web = request.form.get("web")
mail = request.form.get("mail")
if action == "creer":
try:
query = text(
"INSERT INTO societe (nom, forme, capital, siren, siret, rcs, "
"tva, naf, tel, web, mail) "
"VALUES (:nom, :forme, :capital, :siren, :siret, :rcs, :tva, "
":naf, :tel, :web, :mail)"
)
db.session.execute(
query,
{
"nom": nom,
"forme": forme,
"capital": capital,
"siren": siren,
"siret": siret,
"rcs": rcs,
"tva": tva,
"naf": naf,
"tel": tel,
"web": web,
"mail": mail,
},
)
db.session.commit()
flash("Société créée avec succès.", "success")
except Exception as e:
db.session.rollback()
current_app.logger.error("Erreur création société : %s", e, exc_info=True)
flash("Erreur lors de la création de la société.", "danger")
elif action == "modifier" and soc_id:
try:
query = text(
"UPDATE societe SET nom = :nom, forme = :forme, "
"capital = :capital, siren = :siren, siret = :siret, "
"rcs = :rcs, tva = :tva, naf = :naf, tel = :tel, web = :web, "
"mail = :mail WHERE id = :id"
)
db.session.execute(
query,
{
"id": soc_id,
"nom": nom,
"forme": forme,
"capital": capital,
"siren": siren,
"siret": siret,
"rcs": rcs,
"tva": tva,
"naf": naf,
"tel": tel,
"web": web,
"mail": mail,
},
)
db.session.commit()
flash("Société modifiée avec succès.", "success")
except Exception as e:
db.session.rollback()
current_app.logger.error("Erreur modification société : %s", e, exc_info=True)
flash("Erreur lors de la modification de la société.", "danger")
return redirect(url_for("societes.gestion_societes"))
societes_query = text(
"SELECT id, nom, forme, capital, siren, siret, rcs, tva, naf, tel, web, "
"mail FROM societe ORDER BY id ASC"
)
result = db.session.execute(societes_query)
societes = [
{
"id": row.id,
"nom": row.nom if row.nom else "",
"forme": row.forme if row.forme else "",
"capital": row.capital if row.capital is not None else 0,
"siren": row.siren if row.siren is not None else "",
"siret": row.siret if row.siret is not None else "",
"rcs": row.rcs if row.rcs else "",
"tva": row.tva if row.tva else "",
"naf": row.naf if row.naf else "",
"tel": row.tel if row.tel else "",
"web": row.web if row.web else "",
"mail": row.mail if row.mail else "",
}
for row in result
]
return render_template("gestion_societes.html", societes=societes)
+107
View File
@@ -0,0 +1,107 @@
from flask import Blueprint, flash, redirect, render_template, request, url_for
from sqlalchemy import text
from werkzeug.security import generate_password_hash
from app import db
from app.decorators import login_required
utilisateurs_bp = Blueprint("utilisateurs", __name__)
@utilisateurs_bp.route("/gestion-utilisateurs", methods=["GET", "POST"])
@login_required
def gestion_utilisateurs():
if request.method == "POST":
action = request.form.get("action")
user_id = request.form.get("id")
user_actif = request.form.get("actif", "1")
user_prenom = request.form.get("prenom")
user_nom = request.form.get("nom")
user_login = request.form.get("login")
user_mail = request.form.get("mail")
user_pass = request.form.get("pass")
if action == "creer":
if user_pass:
hashed_pass = generate_password_hash(user_pass)
query = text(
"INSERT INTO login (login, nom, prenom, mail, pass, actif) "
"VALUES (:login, :nom, :prenom, :mail, :pass, :actif)"
)
db.session.execute(
query,
{
"login": user_login,
"nom": user_nom,
"prenom": user_prenom,
"mail": user_mail,
"pass": hashed_pass,
"actif": user_actif,
},
)
db.session.commit()
flash("Utilisateur créé avec succès.", "success")
else:
flash(
"Le mot de passe est obligatoire pour créer un utilisateur.",
"danger",
)
elif action == "modifier" and user_id:
if user_pass:
hashed_pass = generate_password_hash(user_pass)
query = text(
"UPDATE login SET login = :login, nom = :nom, prenom = :prenom, "
"mail = :mail, pass = :pass, actif = :actif WHERE id = :id"
)
db.session.execute(
query,
{
"id": user_id,
"login": user_login,
"nom": user_nom,
"prenom": user_prenom,
"mail": user_mail,
"pass": hashed_pass,
"actif": user_actif,
},
)
else:
query = text(
"UPDATE login SET login = :login, nom = :nom, prenom = :prenom, "
"mail = :mail, actif = :actif WHERE id = :id"
)
db.session.execute(
query,
{
"id": user_id,
"login": user_login,
"nom": user_nom,
"prenom": user_prenom,
"mail": user_mail,
"actif": user_actif,
},
)
db.session.commit()
flash("Utilisateur modifié avec succès.", "success")
return redirect(url_for("utilisateurs.gestion_utilisateurs"))
users_query = text(
"SELECT id, login, nom, prenom, mail, actif FROM login ORDER BY id ASC"
)
result = db.session.execute(users_query)
users = [
{
"id": row.id,
"login": row.login if row.login else "",
"nom": row.nom if row.nom else "",
"prenom": row.prenom if row.prenom else "",
"mail": row.mail if row.mail else "",
"actif": row.actif if row.actif is not None else 1,
}
for row in result
]
return render_template("gestion_utilisateurs.html", users=users)
View File
+150
View File
@@ -0,0 +1,150 @@
"""
analysis.py — Orchestrateur de l'analyse technique complète.
La fonction analyse_action(isin) :
1. vérifie l'ISIN dans le référentiel actions
2. récupère l'historique depuis la base
3. calcule tous les indicateurs via indicators.py
4. calcule le score global
5. prépare les données pour les graphiques
6. retourne un dict JSON-sérialisable conforme au format spécifié
"""
from datetime import datetime
from app.services.database import get_historique_df, get_action_info
from app.services import indicators as ind
def analyse_action(isin):
"""Analyse technique complète d'une action par son ISIN.
Retourne un dict JSON-sérialisable ou None si l'ISIN est introuvable.
"""
# --- 1) Vérification de l'ISIN dans le référentiel ---
info = get_action_info(isin)
if not info:
return None
# --- 2) Récupération de l'historique ---
df = get_historique_df(isin)
if df is None or len(df) < 5:
return {
"isin": isin,
"action": info,
"erreur": "Données historiques insuffisantes pour l'analyse (minimum 5 cours requis).",
}
# --- 3) Calcul de tous les indicateurs ---
performances = ind.calcul_performances(df)
moyennes_mobiles = ind.calcul_moyennes_mobiles(df)
volatilite = ind.calcul_volatilite(df)
atr = ind.calcul_atr(df)
gaps = ind.calcul_gaps(df)
rsi = {
"RSI14": ind.calcul_rsi(df, 14),
"RSI7": ind.calcul_rsi(df, 7),
}
macd = ind.calcul_macd(df)
bollinger = ind.calcul_bollinger(df)
supports_resistances = ind.calcul_supports_resistances(df)
volumes = ind.calcul_volumes(df)
drawdown = ind.calcul_drawdown(df)
series = ind.calcul_series(df)
chandeliers = ind.calcul_chandeliers(df)
# --- 4) Score global ---
score_data = ind.calcul_score_global(
df, performances, moyennes_mobiles, volatilite, rsi["RSI14"], macd, drawdown, volumes
)
# --- 5) Données pour les graphiques ---
# On prend les 250 derniers jours pour les graphiques (performance)
nb_points = min(250, len(df))
df_chart = df.tail(nb_points)
graphique_cours = {
"dates": [d.strftime("%Y-%m-%d") for d in df_chart["date"]],
"close": [round(float(p), 2) for p in df_chart["price"]],
"open": [round(float(p), 2) if not _is_nan(p) else None for p in df_chart["open"]],
"high": [round(float(p), 2) if not _is_nan(p) else None for p in df_chart["hight"]],
"low": [round(float(p), 2) if not _is_nan(p) else None for p in df_chart["low"]],
}
# Moyennes mobiles pour le graphique
close = df["price"]
for p in [20, 50, 200]:
sma = close.rolling(window=p).mean()
sma_tail = sma.tail(nb_points).tolist()
graphique_cours[f"MM{p}"] = [round(float(v), 2) if v == v else None for v in sma_tail]
# Volumes pour le graphique
vol_chart = df_chart["vol"].astype(float).tolist()
vol_moy_20 = close.rolling(window=20).mean() # pas le bon, il faut volume
vol_series = df["vol"].astype(float)
vm20_chart = vol_series.rolling(window=20).mean().tail(nb_points).tolist()
graphique_volumes = {
"dates": graphique_cours["dates"],
"volumes": [int(v) if v == v else 0 for v in vol_chart],
"moyenne_20j": [round(float(v), 0) if v == v else None for v in vm20_chart],
}
# Volatilité pour le graphique (20j, fenêtre glissante)
ret = close.pct_change()
vol_20j_series = (ret.rolling(window=20).std() * np_sqrt_252() * 100).tail(nb_points).tolist()
graphique_volatilite = {
"dates": graphique_cours["dates"],
"volatilite_20j": [round(float(v), 2) if v == v else None for v in vol_20j_series],
}
# --- 6) Assemblage du résultat JSON ---
cours_actuel = round(float(df["price"].iloc[-1]), 2)
derniere_date = df["date"].iloc[-1].strftime("%Y-%m-%d")
var_jour = performances.get("variation_journaliere")
result = {
"isin": isin,
"action": info,
"date_analyse": datetime.now().strftime("%Y-%m-%d %H:%M"),
"derniere_date_cours": derniere_date,
"cours": cours_actuel,
"variation_jour": var_jour,
"tendance": score_data["tendance"],
"score": score_data["score"],
"points_positifs": score_data["points_positifs"],
"points_negatifs": score_data["points_negatifs"],
"performances": performances,
"moyennes_mobiles": moyennes_mobiles,
"volatilite": volatilite,
"atr": atr,
"gap": gaps,
"rsi": rsi,
"macd": macd,
"bollinger": bollinger,
"supports_resistances": supports_resistances,
"volumes": volumes,
"drawdown": drawdown,
"series": series,
"chandeliers": chandeliers,
"graphique_cours": graphique_cours,
"graphique_volumes": graphique_volumes,
"graphique_volatilite": graphique_volatilite,
"erreur": None,
}
return result
# ---------------------------------------------------------------------------
# Helpers privés
# ---------------------------------------------------------------------------
def _is_nan(val):
"""Vérifie si une valeur est NaN."""
try:
return val != val # NaN != NaN est True
except (TypeError, ValueError):
return True
def np_sqrt_252():
"""Retourne sqrt(252) sans import répété."""
import math
return math.sqrt(252)
+67
View File
@@ -0,0 +1,67 @@
"""
database.py — Accès aux données historiques pour l'analyse technique.
"""
from sqlalchemy import text
from app import db
def get_historique_df(isin):
"""Récupère l'historique complet d'un ISIN trié par date croissante.
Retourne un DataFrame pandas ou None si aucune donnée.
Colonnes : date, price, open, hight, low, vol, change
"""
import pandas as pd
rows = db.session.execute(
text(
"SELECT date, price, `open`, hight, low, vol, `change` "
"FROM historique WHERE isin = :isin ORDER BY date ASC"
),
{"isin": isin},
).fetchall()
if not rows:
return None
df = pd.DataFrame(rows, columns=["date", "price", "open", "hight", "low", "vol", "change"])
# Conversion des types
df["date"] = pd.to_datetime(df["date"])
df["price"] = pd.to_numeric(df["price"], errors="coerce")
df["open"] = pd.to_numeric(df["open"], errors="coerce")
df["hight"] = pd.to_numeric(df["hight"], errors="coerce")
df["low"] = pd.to_numeric(df["low"], errors="coerce")
df["vol"] = pd.to_numeric(df["vol"], errors="coerce").astype("Int64")
df["change"] = pd.to_numeric(df["change"], errors="coerce")
# Nettoyage : doublons de dates (on garde la dernière)
df = df.drop_duplicates(subset="date", keep="last")
# Suppression des lignes sans prix (essentielles)
df = df.dropna(subset=["price"])
# Tri par date
df = df.sort_values("date").reset_index(drop=True)
return df
def get_action_info(isin):
"""Récupère les infos de l'action (company_name, ticker, currency).
Retourne un dict ou None si l'ISIN n'existe pas dans le référentiel.
"""
row = db.session.execute(
text("SELECT isin, ticker, company_name, currency FROM actions WHERE isin = :isin"),
{"isin": isin},
).fetchone()
if not row:
return None
return {
"isin": row.isin,
"ticker": row.ticker or "",
"company_name": row.company_name or "",
"currency": row.currency or "EUR",
}
+689
View File
@@ -0,0 +1,689 @@
"""
indicators.py — Calcul de tous les indicateurs d'analyse technique.
Chaque fonction prend un DataFrame pandas (colonnes : date, price, open,
hight, low, vol) trié par date croissante et retourne un dict JSON-sérialisable.
"""
import numpy as np
import pandas as pd
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _safe_last(series):
"""Retourne la dernière valeur non-NaN d'une Series, ou None."""
if series is None or len(series) == 0:
return None
val = series.dropna()
return float(val.iloc[-1]) if len(val) > 0 else None
def _round(val, decimals=2):
"""Arrondi sécurisé (None si val est None)."""
if val is None:
return None
return round(float(val), decimals)
# ---------------------------------------------------------------------------
# 3) Performances
# ---------------------------------------------------------------------------
def calcul_performances(df):
"""Variation journalière et performances sur différentes fenêtres."""
close = df["price"]
ret = close.pct_change() * 100 # variation journalière en %
result = {
"variation_journaliere": _round(_safe_last(ret)),
"fenetres": {},
}
for fenetre in [5, 10, 20, 50, 100, 250]:
if len(close) > fenetre:
perf = (close.iloc[-1] / close.iloc[-1 - fenetre] - 1) * 100
result["fenetres"][f"{fenetre}j"] = _round(perf)
else:
result["fenetres"][f"{fenetre}j"] = None
# Performance annuelle (année civile complète la plus récente)
df_temp = df.copy()
df_temp["year"] = df_temp["date"].dt.year
annees = sorted(df_temp["year"].unique(), reverse=True)
if len(annees) >= 2:
annee_ref = annees[1] # dernière année complète
prix_debut = df_temp[df_temp["year"] == annee_ref]["price"].iloc[0]
prix_fin = df_temp[df_temp["year"] == annee_ref]["price"].iloc[-1]
result["performance_annuelle"] = {
"annee": int(annee_ref),
"valeur": _round((prix_fin / prix_debut - 1) * 100),
}
else:
result["performance_annuelle"] = None
# YTD (depuis le 1er janvier de l'année en cours)
annee_courante = annees[0] if annees else None
if annee_courante:
prix_ytd_debut = df_temp[df_temp["year"] == annee_courante]["price"].iloc[0]
prix_ytd_fin = df_temp["price"].iloc[-1]
result["ytd"] = _round((prix_ytd_fin / prix_ytd_debut - 1) * 100)
else:
result["ytd"] = None
return result
# ---------------------------------------------------------------------------
# 4) Moyennes mobiles
# ---------------------------------------------------------------------------
def calcul_moyennes_mobiles(df):
"""SMA (5,10,20,50,100,200) et EMA (12,26) + distance du cours en %."""
close = df["price"]
result = {"sma": {}, "ema": {}}
sma_periods = [5, 10, 20, 50, 100, 200]
for p in sma_periods:
sma = close.rolling(window=p).mean()
val = _safe_last(sma)
result["sma"][f"MM{p}"] = _round(val)
if val:
dist = (close.iloc[-1] / val - 1) * 100
result["sma"][f"MM{p}_distance"] = _round(dist)
for p in [12, 26]:
ema = close.ewm(span=p, adjust=False).mean()
val = _safe_last(ema)
result["ema"][f"EMA{p}"] = _round(val)
if val:
dist = (close.iloc[-1] / val - 1) * 100
result["ema"][f"EMA{p}_distance"] = _round(dist)
return result
# ---------------------------------------------------------------------------
# 5) Volatilité
# ---------------------------------------------------------------------------
def calcul_volatilite(df):
"""Volatilité historique annualisée (std des rendements * sqrt(252))."""
close = df["price"]
ret = close.pct_change().dropna()
result = {"fenetres": {}}
for fenetre in [10, 20, 50]:
if len(ret) >= fenetre:
vol = ret.rolling(window=fenetre).std() * np.sqrt(252) * 100
result["fenetres"][f"{fenetre}j"] = _round(_safe_last(vol))
else:
result["fenetres"][f"{fenetre}j"] = None
# Volatilité actuelle (20j par défaut)
if len(ret) >= 20:
vol_actuelle = ret.rolling(window=20).std().iloc[-1] * np.sqrt(252) * 100
result["actuelle"] = _round(vol_actuelle)
else:
result["actuelle"] = None
# Volatilité moyenne sur 1 an (252 jours)
if len(ret) >= 20:
vol_series = ret.rolling(window=20).std() * np.sqrt(252) * 100
vol_1an = vol_series.dropna().tail(252).mean()
result["moyenne_1an"] = _round(vol_1an)
else:
result["moyenne_1an"] = None
# Percentile de la volatilité actuelle
if len(ret) >= 50 and result["actuelle"] is not None:
vol_series = (ret.rolling(window=20).std() * np.sqrt(252) * 100).dropna()
if len(vol_series) > 0:
percentile = (vol_series < result["actuelle"]).sum() / len(vol_series) * 100
result["percentile"] = _round(percentile)
else:
result["percentile"] = None
else:
result["percentile"] = None
return result
# ---------------------------------------------------------------------------
# 6) ATR
# ---------------------------------------------------------------------------
def calcul_atr(df):
"""Average True Range (14 et 20 périodes)."""
high = df["hight"]
low = df["low"]
close = df["price"]
prev_close = close.shift(1)
tr = pd.concat(
[
(high - low),
(high - prev_close).abs(),
(low - prev_close).abs(),
],
axis=1,
).max(axis=1)
result = {}
for p in [14, 20]:
atr = tr.rolling(window=p).mean()
val = _safe_last(atr)
result[f"ATR{p}"] = _round(val)
if val and close.iloc[-1] > 0:
result[f"ATR{p}_pct"] = _round(val / close.iloc[-1] * 100)
return result
# ---------------------------------------------------------------------------
# 7) Analyse des gaps
# ---------------------------------------------------------------------------
def calcul_gaps(df):
"""Détection et statistiques des gaps (open vs close précédent)."""
open_ = df["open"]
close = df["price"]
dates = df["date"]
prev_close = close.shift(1)
gap_pct = (open_ / prev_close - 1) * 100
# Un gap est significatif si > 0.5% (sinon c'est du bruit)
seuil = 0.5
gaps_significatifs = gap_pct.abs() > seuil
gaps_list = []
for i in range(1, len(df)):
if gaps_significatifs.iloc[i]:
taille = gap_pct.iloc[i]
type_gap = "haussier" if taille > 0 else "baissier"
# Vérifier si le gap a été comblé dans les 30 jours suivants
niveau_gap = open_.iloc[i]
comble = False
jours_combles = None
for j in range(i + 1, min(i + 31, len(df))):
if taille > 0:
# Gap haussier : comblé si prix redescend sous le niveau d'ouverture
if df["low"].iloc[j] <= niveau_gap:
comble = True
jours_combles = j - i
break
else:
# Gap baissier : comblé si prix remonte au-dessus du niveau d'ouverture
if df["hight"].iloc[j] >= niveau_gap:
comble = True
jours_combles = j - i
break
gaps_list.append(
{
"date": dates.iloc[i].strftime("%Y-%m-%d"),
"type": type_gap,
"taille": _round(taille),
"comble": comble,
"jours_combles": jours_combles,
}
)
# Statistiques
nb_gaps = len(gaps_list)
nb_combles = sum(1 for g in gaps_list if g["comble"])
taux_combles = _round(nb_combles / nb_gaps * 100) if nb_gaps > 0 else 0
gap_moyen = _round(np.mean([abs(g["taille"]) for g in gaps_list])) if nb_gaps > 0 else 0
plus_gros_gap = _round(max([abs(g["taille"]) for g in gaps_list], default=0))
# Derniers gaps (les 10 plus récents)
derniers = gaps_list[-10:] if nb_gaps <= 10 else gaps_list[-10:]
return {
"nb_total": nb_gaps,
"taux_combles": taux_combles,
"gap_moyen": gap_moyen,
"plus_gros_gap": plus_gros_gap,
"derniers": derniers,
}
# ---------------------------------------------------------------------------
# 8) RSI
# ---------------------------------------------------------------------------
def calcul_rsi(df, period=14):
"""RSI (Relative Strength Index) — méthode de Wilder."""
close = df["price"]
delta = close.diff()
gain = delta.clip(lower=0)
loss = -delta.clip(upper=0)
# Moyenne mobile exponentielle (méthode Wilder = équivalent ewm alpha=1/period)
avg_gain = gain.ewm(alpha=1 / period, adjust=False).mean()
avg_loss = loss.ewm(alpha=1 / period, adjust=False).mean()
rs = avg_gain / avg_loss
rsi = 100 - (100 / (1 + rs))
val = _safe_last(rsi)
if val is None:
return {"valeur": None, "zone": "N/A"}
if val < 30:
zone = "survente"
elif val > 70:
zone = "surachat"
else:
zone = "neutre"
return {"valeur": _round(val), "zone": zone}
# ---------------------------------------------------------------------------
# 9) MACD
# ---------------------------------------------------------------------------
def calcul_macd(df):
"""MACD (EMA12 - EMA26), Signal (EMA9 du MACD), Histogramme."""
close = df["price"]
ema12 = close.ewm(span=12, adjust=False).mean()
ema26 = close.ewm(span=26, adjust=False).mean()
macd_line = ema12 - ema26
signal_line = macd_line.ewm(span=9, adjust=False).mean()
histogramme = macd_line - signal_line
macd_val = _safe_last(macd_line)
signal_val = _safe_last(signal_line)
hist_val = _safe_last(histogramme)
# Détection de croisement (sur les 2 dernières valeurs valides)
croisement = "neutre"
if len(macd_line) >= 2 and macd_val is not None:
prev_macd = macd_line.iloc[-2]
prev_signal = signal_line.iloc[-2]
if prev_macd is not None and prev_signal is not None:
if prev_macd <= prev_signal and macd_val > signal_val:
croisement = "haussier"
elif prev_macd >= prev_signal and macd_val < signal_val:
croisement = "baissier"
return {
"macd": _round(macd_val),
"signal": _round(signal_val),
"histogramme": _round(hist_val),
"croisement": croisement,
}
# ---------------------------------------------------------------------------
# 10) Bandes de Bollinger
# ---------------------------------------------------------------------------
def calcul_bollinger(df, window=20, nb_std=2):
"""Bandes de Bollinger (MM20 ± 2 écarts-types)."""
close = df["price"]
sma = close.rolling(window=window).mean()
std = close.rolling(window=window).std()
bande_haute = sma + nb_std * std
bande_basse = sma - nb_std * std
cours = close.iloc[-1]
bh = _safe_last(bande_haute)
bb = _safe_last(bande_basse)
mm = _safe_last(sma)
position = None
if bh is not None and bb is not None and (bh - bb) > 0:
position = (cours - bb) / (bh - bb) * 100
position = _round(position)
return {
"bande_haute": _round(bh),
"bande_basse": _round(bb),
"mm20": _round(mm),
"position": position,
}
# ---------------------------------------------------------------------------
# 11) Supports et résistances
# ---------------------------------------------------------------------------
def calcul_supports_resistances(df):
"""Plus hauts et plus bas sur différentes fenêtres."""
close = df["price"]
cours_actuel = close.iloc[-1]
result = {"plus_haut": {}, "plus_bas": {}, "distances": {}}
for p in [20, 50, 100, 250]:
if len(df) >= p:
ph = df["hight"].tail(p).max()
pb = df["low"].tail(p).min()
result["plus_haut"][f"{p}j"] = _round(ph)
result["plus_bas"][f"{p}j"] = _round(pb)
else:
result["plus_haut"][f"{p}j"] = None
result["plus_bas"][f"{p}j"] = None
# Distances par rapport au plus haut / plus bas annuel (250j)
ph_250 = result["plus_haut"].get("250j")
pb_250 = result["plus_bas"].get("250j")
if ph_250:
result["distances"]["plus_haut_annuel"] = _round(
(cours_actuel / ph_250 - 1) * 100
)
if pb_250:
result["distances"]["plus_bas_annuel"] = _round(
(cours_actuel / pb_250 - 1) * 100
)
# Support et résistance les plus proches du cours actuel
tous_plus_bas = [v for v in result["plus_bas"].values() if v is not None and v < cours_actuel]
tous_plus_haut = [v for v in result["plus_haut"].values() if v is not None and v > cours_actuel]
if tous_plus_bas:
support_proche = max(tous_plus_bas)
result["support_proche"] = _round(support_proche)
else:
result["support_proche"] = None
if tous_plus_haut:
resistance_proche = min(tous_plus_haut)
result["resistance_proche"] = _round(resistance_proche)
else:
result["resistance_proche"] = None
return result
# ---------------------------------------------------------------------------
# 12) Analyse du volume
# ---------------------------------------------------------------------------
def calcul_volumes(df):
"""Analyse des volumes : moyennes mobiles et volume relatif."""
vol = df["vol"].astype(float)
vol_moy_20 = vol.rolling(window=20).mean()
vol_moy_50 = vol.rolling(window=50).mean()
vol_actuel = vol.iloc[-1] if len(vol) > 0 else None
vm20 = _safe_last(vol_moy_20)
vm50 = _safe_last(vol_moy_50)
vol_relatif = None
if vol_actuel is not None and vm20 and vm20 > 0:
vol_relatif = vol_actuel / vm20
vol_relatif = _round(vol_relatif, 2)
volume_exceptionnel = False
if vol_relatif is not None and vol_relatif >= 2.0:
volume_exceptionnel = True
return {
"volume_actuel": int(vol_actuel) if vol_actuel is not None else None,
"moyenne_20j": int(vm20) if vm20 else None,
"moyenne_50j": int(vm50) if vm50 else None,
"volume_relatif": vol_relatif,
"exceptionnel": volume_exceptionnel,
}
# ---------------------------------------------------------------------------
# 13) Drawdown
# ---------------------------------------------------------------------------
def calcul_drawdown(df):
"""Drawdown : écart par rapport au plus haut historique de la période."""
close = df["price"]
running_max = close.cummax()
drawdown = (close / running_max - 1) * 100
dd_actuel = _safe_last(drawdown)
dd_max = drawdown.min()
# Durée de récupération : nombre de jours depuis le dernier plus haut
# avant le point bas le plus récent
duree_recup = None
idx_dd_max = drawdown.idxmin()
if idx_dd_max is not None:
# On cherche le plus haut précédant le drawdown max
avant = close.iloc[: idx_dd_max + 1]
if len(avant) > 0:
idx_peak = avant.idxmax()
duree_recup = int(idx_dd_max - idx_peak)
return {
"actuel": _round(dd_actuel),
"maximum": _round(dd_max),
"duree_recuperation_jours": duree_recup,
}
# ---------------------------------------------------------------------------
# 14) Analyse des séries (jours consécutifs hausse/baisse)
# ---------------------------------------------------------------------------
def calcul_series(df):
"""Séries de jours hausse/baisse consécutifs + records."""
close = df["price"]
ret = close.pct_change()
# Direction : 1 = hausse, -1 = baisse, 0 = stable
direction = np.sign(ret)
direction.iloc[0] = 0 # 1er jour : pas de référence
# Compter les jours consécutifs actuels
serie_hausse = 0
serie_baisse = 0
if len(direction) >= 2:
derniere = direction.iloc[-1]
if derniere > 0:
# Compter en arrière
for i in range(len(direction) - 1, 0, -1):
if direction.iloc[i] > 0:
serie_hausse += 1
else:
break
elif derniere < 0:
for i in range(len(direction) - 1, 0, -1):
if direction.iloc[i] < 0:
serie_baisse += 1
else:
break
# Plus forte hausse / baisse journalière
plus_forte_hausse = ret.max() * 100 if len(ret) > 0 else None
plus_forte_baisse = ret.min() * 100 if len(ret) > 0 else None
return {
"jours_hausse_consecutifs": serie_hausse,
"jours_baisse_consecutifs": serie_baisse,
"plus_forte_hausse": _round(plus_forte_hausse),
"plus_forte_baisse": _round(plus_forte_baisse),
}
# ---------------------------------------------------------------------------
# 15) Chandeliers japonais
# ---------------------------------------------------------------------------
def calcul_chandeliers(df):
"""Détection des patterns de chandeliers japonais sur les derniers jours."""
open_ = df["open"]
high = df["hight"]
low = df["low"]
close = df["price"]
# Corps et ombres
body = (close - open_).abs()
range_total = (high - low).replace(0, np.nan)
lower_shadow = (open_.where(open_ < close, close) - low)
upper_shadow = (high - open_.where(open_ > close, close))
patterns_detectes = []
# On analyse les 10 derniers jours
for i in range(max(1, len(df) - 10), len(df)):
o, h, l, c = open_.iloc[i], high.iloc[i], low.iloc[i], close.iloc[i]
b = body.iloc[i]
r = range_total.iloc[i]
ls = lower_shadow.iloc[i]
us = upper_shadow.iloc[i]
if r is None or r == 0 or np.isnan(r):
continue
# Doji : corps très petit (< 10% du range)
if b / r < 0.1:
patterns_detectes.append({"date": df["date"].iloc[i].strftime("%Y-%m-%d"), "pattern": "Doji"})
# Marteau : petit corps en haut, longue ombre basse (> 2x corps)
elif b > 0 and ls > 2 * b and us < b * 0.5:
if c > o:
patterns_detectes.append(
{"date": df["date"].iloc[i].strftime("%Y-%m-%d"), "pattern": "Marteau"}
)
# Marteau inversé : petit corps en bas, longue ombre haute (> 2x corps)
elif b > 0 and us > 2 * b and ls < b * 0.5:
patterns_detectes.append(
{"date": df["date"].iloc[i].strftime("%Y-%m-%d"), "pattern": "Marteau inversé"}
if c > o
else {"date": df["date"].iloc[i].strftime("%Y-%m-%d"), "pattern": "Étoile filante"}
)
# Englobante : besoin de la bougie précédente
if i > 0:
prev_o, prev_c = open_.iloc[i - 1], close.iloc[i - 1]
# Englobante haussière : bougie précédente baissière, actuelle haussière et englobe
if prev_c < prev_o and c > o and o <= prev_c and c >= prev_o:
patterns_detectes.append(
{"date": df["date"].iloc[i].strftime("%Y-%m-%d"), "pattern": "Englobante haussière"}
)
# Englobante baissière : bougie précédente haussière, actuelle baissière et englobe
elif prev_c > prev_o and c < o and o >= prev_c and c <= prev_o:
patterns_detectes.append(
{"date": df["date"].iloc[i].strftime("%Y-%m-%d"), "pattern": "Englobante baissière"}
)
# Ne garder que les patterns du dernier jour pour le statut actuel
derniere_date = df["date"].iloc[-1].strftime("%Y-%m-%d") if len(df) > 0 else ""
pattern_actuel = [p["pattern"] for p in patterns_detectes if p["date"] == derniere_date]
return {
"pattern_actuel": pattern_actuel[0] if pattern_actuel else "Aucun",
"derniers_detectes": patterns_detectes[-10:] if len(patterns_detectes) > 10 else patterns_detectes,
}
# ---------------------------------------------------------------------------
# 16) Score global
# ---------------------------------------------------------------------------
def calcul_score_global(df, perf, mm, vol, rsi_data, macd_data, dd, volumes):
"""Score global de 0 à 100 basé sur une combinaison d'indicateurs.
Retourne (score, tendance, points_positifs, points_negatifs).
"""
score = 50 # neutre de base
positifs = []
negatifs = []
close = df["price"].iloc[-1]
# Cours > MM200
mm200 = mm["sma"].get("MM200")
if mm200:
if close > mm200:
score += 10
positifs.append("Cours supérieur à MM200")
else:
score -= 10
negatifs.append("Cours sous MM200")
# Cours > MM50
mm50 = mm["sma"].get("MM50")
if mm50:
if close > mm50:
score += 5
positifs.append("Cours supérieur à MM50")
else:
score -= 5
negatifs.append("Cours sous MM50")
# RSI
rsi_val = rsi_data["valeur"]
if rsi_val is not None:
if rsi_val < 30:
score += 5
positifs.append(f"RSI en zone survendue ({rsi_val})")
elif rsi_val > 70:
score -= 10
negatifs.append(f"RSI en zone surachat ({rsi_val})")
elif 40 <= rsi_val <= 60:
score += 3
positifs.append("RSI neutre")
# MACD
if macd_data["histogramme"] is not None:
if macd_data["histogramme"] > 0:
score += 8
positifs.append("MACD positif")
else:
score -= 8
negatifs.append("MACD négatif")
if macd_data["croisement"] == "haussier":
score += 5
positifs.append("Croisement MACD haussier")
elif macd_data["croisement"] == "baissier":
score -= 5
negatifs.append("Croisement MACD baissier")
# Volume
if volumes["volume_relatif"] is not None:
if volumes["volume_relatif"] > 1.5:
score += 5
positifs.append("Volume supérieur à la moyenne")
elif volumes["volume_relatif"] < 0.5:
score -= 3
negatifs.append("Volume faible")
# Drawdown
if dd["actuel"] is not None:
if dd["actuel"] < -20:
score -= 10
negatifs.append(f"Drawdown important ({dd['actuel']}%)")
elif dd["actuel"] > -5:
score += 5
positifs.append("Drawdown modéré")
# Volatilité
if vol["actuelle"] is not None and vol["moyenne_1an"] is not None:
if vol["actuelle"] > vol["moyenne_1an"] * 1.5:
score -= 5
negatifs.append("Volatilité excessive")
elif vol["actuelle"] < vol["moyenne_1an"] * 0.8:
score += 3
positifs.append("Volatilité contained")
# Performance 20j
perf_20 = perf["fenetres"].get("20j")
if perf_20 is not None:
if perf_20 > 5:
score += 5
positifs.append("Performance 20j positive")
elif perf_20 < -5:
score -= 5
negatifs.append("Performance 20j négative")
# Bornage 0-100
score = max(0, min(100, score))
# Tendance
if score >= 65:
tendance = "haussiere"
elif score <= 35:
tendance = "baissiere"
else:
tendance = "neutre"
return {
"score": score,
"tendance": tendance,
"points_positifs": positifs,
"points_negatifs": negatifs,
}
+200
View File
@@ -0,0 +1,200 @@
/**
* charts.js — Graphiques Chart.js pour l'analyse technique.
* - Graphique principal : cours de clôture + MM20/MM50/MM200 (toggle)
* - Graphique volume : histogramme + moyenne 20j
* - Graphique volatilité : courbe 20j
*
* Utilise un axe X catégoriel (dates en labels) pour éviter toute
* dépendance à un adapter temporel.
*/
document.addEventListener("DOMContentLoaded", () => {
const dataCoursEl = document.getElementById("data-cours");
const dataVolEl = document.getElementById("data-volumes");
const dataVolatEl = document.getElementById("data-volatilite");
if (!dataCoursEl) return; // pas de données (page d'accueil)
let dataCours, dataVol, dataVolat;
try {
dataCours = JSON.parse(dataCoursEl.textContent);
dataVol = JSON.parse(dataVolEl.textContent);
dataVolat = JSON.parse(dataVolatEl.textContent);
} catch (e) {
console.error("Erreur parsing JSON graphiques:", e);
return;
}
console.log("[charts] données chargées:",
"cours=" + dataCours.dates.length + "pts",
"vol=" + dataVol.volumes.length + "pts");
// --- Configuration globale Chart.js ---
if (typeof Chart === "undefined") {
console.error("[charts] Chart.js non chargé");
return;
}
Chart.defaults.color = "#9ca3af";
Chart.defaults.borderColor = "#1f2937";
Chart.defaults.font.family = "system-ui, sans-serif";
const labels = dataCours.dates;
// ===================== GRAPHIQUE PRINCIPAL =====================
const datasetsCours = [
{
label: "Cours",
data: dataCours.close,
borderColor: "#60a5fa",
backgroundColor: "rgba(96, 165, 250, 0.1)",
borderWidth: 1.5,
pointRadius: 0,
fill: true,
tension: 0.1,
},
];
if (dataCours.MM20) {
datasetsCours.push({
label: "MM20", data: dataCours.MM20, borderColor: "#3b82f6",
borderWidth: 1.2, pointRadius: 0, fill: false, tension: 0.1, hidden: false, id: "mm20",
});
}
if (dataCours.MM50) {
datasetsCours.push({
label: "MM50", data: dataCours.MM50, borderColor: "#10b981",
borderWidth: 1.2, pointRadius: 0, fill: false, tension: 0.1, hidden: false, id: "mm50",
});
}
if (dataCours.MM200) {
datasetsCours.push({
label: "MM200", data: dataCours.MM200, borderColor: "#f59e0b",
borderWidth: 1.2, pointRadius: 0, fill: false, tension: 0.1, hidden: false, id: "mm200",
});
}
const ctxCours = document.getElementById("chartCours");
if (ctxCours) {
const chartCours = new Chart(ctxCours, {
type: "line",
data: { labels: labels, datasets: datasetsCours },
options: {
responsive: true,
maintainAspectRatio: false,
interaction: { intersect: false, mode: "index" },
scales: {
x: {
grid: { color: "#1f2937" },
ticks: { maxTicksLimit: 10, autoSkip: true },
},
y: {
position: "right",
grid: { color: "#1f2937" },
ticks: { callback: v => v.toFixed(2) },
},
},
plugins: {
legend: { display: false },
tooltip: {
backgroundColor: "#111827", borderColor: "#374151", borderWidth: 1,
callbacks: { label: ctx => `${ctx.dataset.label}: ${ctx.parsed.y !== null ? ctx.parsed.y.toFixed(2) : 'N/A'}` },
},
},
},
});
document.getElementById("toggle-mm20")?.addEventListener("change", e => {
const ds = chartCours.data.datasets.find(d => d.id === "mm20");
if (ds) ds.hidden = !e.target.checked;
chartCours.update();
});
document.getElementById("toggle-mm50")?.addEventListener("change", e => {
const ds = chartCours.data.datasets.find(d => d.id === "mm50");
if (ds) ds.hidden = !e.target.checked;
chartCours.update();
});
document.getElementById("toggle-mm200")?.addEventListener("change", e => {
const ds = chartCours.data.datasets.find(d => d.id === "mm200");
if (ds) ds.hidden = !e.target.checked;
chartCours.update();
});
}
// ===================== GRAPHIQUE VOLUME =====================
const ctxVol = document.getElementById("chartVolume");
if (ctxVol) {
const volColors = dataVol.volumes.map((v, i) => {
const vm = dataVol.moyenne_20j[i];
return vm && v > vm * 2 ? "#f59e0b" : "#3b82f6";
});
new Chart(ctxVol, {
type: "bar",
data: {
labels: dataVol.dates,
datasets: [
{ label: "Volume", data: dataVol.volumes, backgroundColor: volColors, borderWidth: 0, order: 2 },
{
label: "Moyenne 20j", type: "line", data: dataVol.moyenne_20j,
borderColor: "#10b981", borderWidth: 1.5, pointRadius: 0, fill: false, tension: 0.1, order: 1,
},
],
},
options: {
responsive: true,
maintainAspectRatio: false,
interaction: { intersect: false, mode: "index" },
scales: {
x: { display: false },
y: {
position: "right", grid: { color: "#1f2937" },
ticks: { callback: v => v >= 1e6 ? (v / 1e6).toFixed(1) + "M" : v >= 1e3 ? (v / 1e3).toFixed(0) + "K" : v },
},
},
plugins: {
legend: { display: false },
tooltip: {
backgroundColor: "#111827", borderColor: "#374151", borderWidth: 1,
callbacks: { label: ctx => ctx.dataset.label + ": " + (ctx.parsed.y !== null ? ctx.parsed.y.toLocaleString("fr-FR") : "N/A") },
},
},
},
});
}
// ===================== GRAPHIQUE VOLATILITÉ =====================
const ctxVolat = document.getElementById("chartVolatilite");
if (ctxVolat) {
new Chart(ctxVolat, {
type: "line",
data: {
labels: dataVolat.dates,
datasets: [
{
label: "Volatilité 20j", data: dataVolat.volatilite_20j,
borderColor: "#f59e0b", backgroundColor: "rgba(245, 158, 11, 0.1)",
borderWidth: 1.5, pointRadius: 0, fill: true, tension: 0.2,
},
],
},
options: {
responsive: true,
maintainAspectRatio: false,
interaction: { intersect: false, mode: "index" },
scales: {
x: { grid: { color: "#1f2937" }, ticks: { maxTicksLimit: 10, autoSkip: true } },
y: { position: "right", grid: { color: "#1f2937" }, ticks: { callback: v => v.toFixed(1) + "%" } },
},
plugins: {
legend: { display: false },
tooltip: {
backgroundColor: "#111827", borderColor: "#374151", borderWidth: 1,
callbacks: { label: ctx => "Volatilité: " + (ctx.parsed.y !== null ? ctx.parsed.y.toFixed(2) : "N/A") + "%" },
},
},
},
});
}
console.log("[charts] graphiques initialisés");
});
+388
View File
@@ -0,0 +1,388 @@
{% extends "base.html" %}
{% block title %}Analyse technique - Bolsa{% endblock %}
{% block header_title %}Analyse technique{% endblock %}
{% block content %}
<div class="flex flex-col items-center justify-start py-4 px-2 w-full h-full overflow-y-auto">
<!-- Toasts -->
<div id="toast-container" class="fixed top-6 left-1/2 transform -translate-x-1/2 z-50 space-y-3 w-full max-w-md px-4 pointer-events-none">
{% set messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="toast-message pointer-events-auto flex items-center justify-center px-4 py-3 rounded-xl shadow-2xl text-white text-sm font-medium transition-all duration-300
{% if category == 'success' %}bg-emerald-600 border border-emerald-500{% else %}bg-rose-600 border border-rose-500{% endif %}">
<span>{{ message }}</span>
</div>
{% endfor %}
{% endif %}
</div>
<!-- Barre de recherche ISIN -->
<div class="bg-gray-900 border border-gray-800 rounded-2xl shadow-2xl w-full max-w-[98%] p-4 mb-4">
<form method="GET" action="{{ url_for('analyse.analyse') }}" class="flex items-center gap-3 flex-wrap">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
<div class="flex-1 min-w-[200px]">
<label class="block text-xs font-medium text-gray-400 mb-1">Entrer un ISIN</label>
<input type="text" name="isin" id="input-isin-analyse" required
value="{{ isin_recherche or '' }}"
placeholder="Ex : FR0000120271"
class="w-full bg-gray-950 border border-gray-800 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-blue-500 uppercase" />
</div>
<button type="submit" class="px-5 py-2.5 bg-blue-600 hover:bg-blue-500 text-white text-sm font-semibold rounded-lg transition cursor-pointer self-end">
<i class="fa-solid fa-magnifying-glass-chart"></i> Analyser
</button>
<a
href="{{ url_for('menu.menu') }}"
class="px-5 py-2.5 bg-gray-800 hover:bg-gray-700 text-gray-200 text-sm font-semibold rounded-lg transition duration-200 cursor-pointer self-end"
>
<i class="fa-solid fa-house"></i> Retour au menu
</a>
</form>
</div>
{% if resultat %}
{% if resultat.erreur %}
<!-- Message d'erreur -->
<div class="bg-rose-950 border border-rose-800 rounded-xl p-6 text-center w-full max-w-[98%]">
<i class="fa-solid fa-triangle-exclamation text-rose-400 text-3xl mb-3"></i>
<p class="text-rose-300 text-sm">{{ resultat.erreur }}</p>
{% if resultat.action %}
<p class="text-gray-400 text-xs mt-2">Action : {{ resultat.action.company_name }} ({{ resultat.isin }})</p>
{% endif %}
</div>
{% else %}
<!-- ===================== EN-TÊTE FICHE ACTION ===================== -->
<div class="bg-gray-900 border border-gray-800 rounded-2xl shadow-2xl w-full max-w-[98%] p-6 mb-4">
<div class="flex flex-wrap items-start justify-between gap-4">
<div>
<h2 class="text-3xl font-extrabold text-white">{{ resultat.action.company_name }}</h2>
<p class="text-gray-400 text-sm font-mono mt-1">{{ resultat.isin }}
{% if resultat.action.ticker %} · {{ resultat.action.ticker }}{% endif %}
</p>
<div class="flex items-center gap-4 mt-3">
<span class="text-2xl font-bold text-white">{{ "{:,.2f}".format(resultat.cours) }} {{ resultat.action.currency or '€' }}</span>
{% set vj = resultat.variation_jour %}
<span class="text-lg font-semibold {% if vj and vj >= 0 %}text-emerald-400{% elif vj %}text-rose-400{% else %}text-gray-400{% endif %}">
{% if vj %}{{ "{:+,.2f}".format(vj) }} %{% else %}--{% endif %}
</span>
</div>
<p class="text-gray-500 text-xs mt-1">Dernier cours : {{ resultat.derniere_date_cours }}</p>
</div>
<div class="text-right">
<div class="inline-block bg-gray-950 border border-gray-800 rounded-xl px-5 py-3">
<p class="text-xs text-gray-400 uppercase tracking-wider">Score</p>
<p class="text-4xl font-extrabold
{% if resultat.score >= 65 %}text-emerald-400{% elif resultat.score <= 35 %}text-rose-400{% else %}text-amber-400{% endif %}">
{{ resultat.score }}/100
</p>
</div>
<p class="mt-2 text-sm font-semibold
{% if resultat.tendance == 'haussiere' %}text-emerald-400{% elif resultat.tendance == 'baissiere' %}text-rose-400{% else %}text-amber-400{% endif %}">
{% if resultat.tendance == 'haussiere' %}🟢 Haussière{% elif resultat.tendance == 'baissiere' %}🔴 Baissière{% else %}🟡 Neutre{% endif %}
</p>
</div>
</div>
</div>
<!-- ===================== CARTES INDICATEURS ===================== -->
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-7 gap-3 w-full max-w-[98%] mb-4">
<!-- RSI -->
{% set rsi14 = resultat.rsi.RSI14 %}
<div class="bg-gray-900 border border-gray-800 rounded-xl p-3 text-center">
<p class="text-xs text-gray-400 uppercase tracking-wider mb-1">RSI 14</p>
<p class="text-2xl font-bold {% if rsi14.valeur and rsi14.valeur < 30 %}text-emerald-400{% elif rsi14.valeur and rsi14.valeur > 70 %}text-rose-400{% else %}text-white{% endif %}">
{{ rsi14.valeur if rsi14.valeur is not none else '--' }}
</p>
<p class="text-[10px] {% if rsi14.zone == 'survente' %}text-emerald-400{% elif rsi14.zone == 'surachat' %}text-rose-400{% else %}text-gray-500{% endif %} uppercase">{{ rsi14.zone }}</p>
</div>
<!-- MACD -->
{% set macd = resultat.macd %}
<div class="bg-gray-900 border border-gray-800 rounded-xl p-3 text-center">
<p class="text-xs text-gray-400 uppercase tracking-wider mb-1">MACD</p>
<p class="text-2xl font-bold {% if macd.histogramme and macd.histogramme > 0 %}text-emerald-400{% elif macd.histogramme and macd.histogramme < 0 %}text-rose-400{% else %}text-white{% endif %}">
{{ "{:+.2f}".format(macd.histogramme) if macd.histogramme is not none else '--' }}
</p>
<p class="text-[10px] {% if macd.croisement == 'haussier' %}text-emerald-400{% elif macd.croisement == 'baissier' %}text-rose-400{% else %}text-gray-500{% endif %} uppercase">{{ macd.croisement }}</p>
</div>
<!-- ATR14 -->
<div class="bg-gray-900 border border-gray-800 rounded-xl p-3 text-center">
<p class="text-xs text-gray-400 uppercase tracking-wider mb-1">ATR 14</p>
<p class="text-2xl font-bold text-white">{{ resultat.atr.ATR14 if resultat.atr.ATR14 is not none else '--' }}</p>
<p class="text-[10px] text-gray-500">{{ resultat.atr.ATR14_pct if resultat.atr.ATR14_pct is not none else '--' }}% du cours</p>
</div>
<!-- Volatilité -->
<div class="bg-gray-900 border border-gray-800 rounded-xl p-3 text-center">
<p class="text-xs text-gray-400 uppercase tracking-wider mb-1">Volatilité 20j</p>
<p class="text-2xl font-bold text-white">{{ resultat.volatilite.actuelle if resultat.volatilite.actuelle is not none else '--' }}%</p>
<p class="text-[10px] text-gray-500">Moy 1an : {{ resultat.volatilite.moyenne_1an if resultat.volatilite.moyenne_1an is not none else '--' }}%</p>
</div>
<!-- Bollinger -->
<div class="bg-gray-900 border border-gray-800 rounded-xl p-3 text-center">
<p class="text-xs text-gray-400 uppercase tracking-wider mb-1">Bollinger</p>
<p class="text-2xl font-bold text-white">{{ resultat.bollinger.position if resultat.bollinger.position is not none else '--' }}%</p>
<p class="text-[10px] text-gray-500">position dans les bandes</p>
</div>
<!-- Volume relatif -->
<div class="bg-gray-900 border border-gray-800 rounded-xl p-3 text-center">
<p class="text-xs text-gray-400 uppercase tracking-wider mb-1">Volume relatif</p>
<p class="text-2xl font-bold {% if resultat.volumes.exceptionnel %}text-amber-400{% else %}text-white{% endif %}">
{{ resultat.volumes.volume_relatif if resultat.volumes.volume_relatif is not none else '--' }}x
</p>
<p class="text-[10px] {% if resultat.volumes.exceptionnel %}text-amber-400{% else %}text-gray-500{% endif %}">{% if resultat.volumes.exceptionnel %}Exceptionnel{% else %}Normal{% endif %}</p>
</div>
<!-- Drawdown -->
<div class="bg-gray-900 border border-gray-800 rounded-xl p-3 text-center">
<p class="text-xs text-gray-400 uppercase tracking-wider mb-1">Drawdown</p>
<p class="text-2xl font-bold {% if resultat.drawdown.actuel and resultat.drawdown.actuel < -10 %}text-rose-400{% else %}text-white{% endif %}">
{{ "{:.1f}".format(resultat.drawdown.actuel) if resultat.drawdown.actuel is not none else '--' }}%
</p>
<p class="text-[10px] text-gray-500">Max : {{ "{:.1f}".format(resultat.drawdown.maximum) if resultat.drawdown.maximum is not none else '--' }}%</p>
</div>
</div>
<!-- ===================== GRAPHIQUE PRINCIPAL ===================== -->
<div class="bg-gray-900 border border-gray-800 rounded-2xl shadow-2xl w-full max-w-[98%] p-4 mb-4">
<div class="flex items-center justify-between mb-3">
<h3 class="text-sm font-semibold text-gray-300 uppercase tracking-wider"><i class="fa-solid fa-chart-line text-blue-500"></i> Cours & Moyennes mobiles</h3>
<div class="flex gap-2 text-xs">
<label class="flex items-center gap-1 cursor-pointer"><input type="checkbox" id="toggle-mm20" checked class="accent-blue-500"> MM20</label>
<label class="flex items-center gap-1 cursor-pointer"><input type="checkbox" id="toggle-mm50" checked class="accent-emerald-500"> MM50</label>
<label class="flex items-center gap-1 cursor-pointer"><input type="checkbox" id="toggle-mm200" checked class="accent-amber-500"> MM200</label>
</div>
</div>
<div style="height: 380px;"><canvas id="chartCours"></canvas></div>
</div>
<!-- ===================== GRAPHIQUE VOLUME ===================== -->
<div class="bg-gray-900 border border-gray-800 rounded-2xl shadow-2xl w-full max-w-[98%] p-4 mb-4">
<h3 class="text-sm font-semibold text-gray-300 uppercase tracking-wider mb-3"><i class="fa-solid fa-chart-column text-blue-500"></i> Volumes</h3>
<div style="height: 180px;"><canvas id="chartVolume"></canvas></div>
</div>
<!-- ===================== GRAPHIQUE VOLATILITÉ ===================== -->
<div class="bg-gray-900 border border-gray-800 rounded-2xl shadow-2xl w-full max-w-[98%] p-4 mb-4">
<h3 class="text-sm font-semibold text-gray-300 uppercase tracking-wider mb-3"><i class="fa-solid fa-wave-square text-blue-500"></i> Volatilité 20j annualisée</h3>
<div style="height: 180px;"><canvas id="chartVolatilite"></canvas></div>
</div>
<!-- ===================== SUPPORTS & RÉSISTANCES ===================== -->
<div class="bg-gray-900 border border-gray-800 rounded-2xl shadow-2xl w-full max-w-[98%] p-4 mb-4">
<h3 class="text-sm font-semibold text-gray-300 uppercase tracking-wider mb-3"><i class="fa-solid fa-arrows-up-down text-blue-500"></i> Supports & Résistances</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- Tableau -->
<div>
<table class="w-full text-sm text-gray-300">
<thead><tr class="text-gray-400 text-xs uppercase border-b border-gray-800"><th class="py-2 px-3 text-left">Fenêtre</th><th class="py-2 px-3 text-right">Plus Haut</th><th class="py-2 px-3 text-right">Plus Bas</th></tr></thead>
<tbody>
{% for fenetre in ['20j', '50j', '100j', '250j'] %}
<tr class="border-b border-gray-800/50">
<td class="py-2 px-3">{{ fenetre }}</td>
<td class="py-2 px-3 text-right text-emerald-400">{{ "{:,.2f}".format(resultat.supports_resistances.plus_haut[fenetre]) if resultat.supports_resistances.plus_haut[fenetre] is not none else '--' }}</td>
<td class="py-2 px-3 text-right text-rose-400">{{ "{:,.2f}".format(resultat.supports_resistances.plus_bas[fenetre]) if resultat.supports_resistances.plus_bas[fenetre] is not none else '--' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<div class="mt-3 space-y-1 text-xs">
<p class="text-gray-400">Support proche : <span class="text-emerald-400 font-bold">{{ "{:,.2f}".format(resultat.supports_resistances.support_proche) if resultat.supports_resistances.support_proche is not none else '--' }} {{ resultat.action.currency or '€' }}</span></p>
<p class="text-gray-400">Résistance proche : <span class="text-rose-400 font-bold">{{ "{:,.2f}".format(resultat.supports_resistances.resistance_proche) if resultat.supports_resistances.resistance_proche is not none else '--' }} {{ resultat.action.currency or '€' }}</span></p>
</div>
</div>
<!-- Barre visuelle 52 sem -->
<div>
{% set pb52 = resultat.supports_resistances.plus_bas['250j'] %}
{% set ph52 = resultat.supports_resistances.plus_haut['250j'] %}
{% set cours = resultat.cours %}
{% if pb52 is not none and ph52 is not none and ph52 > pb52 %}
{% set pct_pos = ((cours - pb52) / (ph52 - pb52) * 100) | float %}
<div class="relative h-8 bg-gray-950 rounded-lg border border-gray-800 overflow-hidden">
<div class="absolute top-0 left-0 h-full bg-gradient-to-r from-rose-900 via-amber-900 to-emerald-900 opacity-30" style="width: 100%"></div>
<div class="absolute top-0 h-full w-1 bg-white" style="left: {{ pct_pos }}%;"></div>
</div>
<div class="flex justify-between text-xs mt-1">
<span class="text-rose-400">{{ "{:,.2f}".format(pb52) }}</span>
<span class="text-gray-400">Cours : {{ "{:,.2f}".format(cours) }}</span>
<span class="text-emerald-400">{{ "{:,.2f}".format(ph52) }}</span>
</div>
{% endif %}
</div>
</div>
</div>
<!-- ===================== ANALYSE DES GAPS ===================== -->
<div class="bg-gray-900 border border-gray-800 rounded-2xl shadow-2xl w-full max-w-[98%] p-4 mb-4">
<h3 class="text-sm font-semibold text-gray-300 uppercase tracking-wider mb-3"><i class="fa-solid fa-arrows-left-right-to-line text-blue-500"></i> Analyse des gaps</h3>
<div class="grid grid-cols-1 md:grid-cols-4 gap-3 mb-3">
<div class="bg-gray-950 border border-gray-800 rounded-lg p-3 text-center">
<p class="text-xs text-gray-400">Total gaps</p>
<p class="text-xl font-bold text-white">{{ resultat.gap.nb_total }}</p>
</div>
<div class="bg-gray-950 border border-gray-800 rounded-lg p-3 text-center">
<p class="text-xs text-gray-400">Taux comblés</p>
<p class="text-xl font-bold text-emerald-400">{{ resultat.gap.taux_combles }}%</p>
</div>
<div class="bg-gray-950 border border-gray-800 rounded-lg p-3 text-center">
<p class="text-xs text-gray-400">Gap moyen</p>
<p class="text-xl font-bold text-white">{{ resultat.gap.gap_moyen }}%</p>
</div>
<div class="bg-gray-950 border border-gray-800 rounded-lg p-3 text-center">
<p class="text-xs text-gray-400">Plus gros gap</p>
<p class="text-xl font-bold text-amber-400">{{ resultat.gap.plus_gros_gap }}%</p>
</div>
</div>
{% if resultat.gap.derniers %}
<table class="w-full text-sm text-gray-300">
<thead><tr class="text-gray-400 text-xs uppercase border-b border-gray-800">
<th class="py-2 px-3 text-left">Date</th><th class="py-2 px-3 text-left">Type</th><th class="py-2 px-3 text-right">Taille</th><th class="py-2 px-3 text-center">Comblé</th>
</tr></thead>
<tbody>
{% for g in resultat.gap.derniers %}
<tr class="border-b border-gray-800/50">
<td class="py-2 px-3">{{ g.date }}</td>
<td class="py-2 px-3 {% if g.type == 'haussier' %}text-emerald-400{% else %}text-rose-400{% endif %}">Gap {{ g.type }}</td>
<td class="py-2 px-3 text-right">{{ "{:+.2f}".format(g.taille) }}%</td>
<td class="py-2 px-3 text-center">{% if g.comble %}<span class="text-emerald-400">✓ Oui ({{ g.jours_combles }}j)</span>{% else %}<span class="text-rose-400">✗ Non</span>{% endif %}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="text-gray-500 text-sm text-center py-4">Aucun gap significatif détecté.</p>
{% endif %}
</div>
<!-- ===================== SIGNAL SYNTHÉTIQUE ===================== -->
<div class="bg-gray-900 border border-gray-800 rounded-2xl shadow-2xl w-full max-w-[98%] p-4 mb-4">
<h3 class="text-sm font-semibold text-gray-300 uppercase tracking-wider mb-3"><i class="fa-solid fa-clipboard-check text-blue-500"></i> Analyse automatique</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<!-- Points positifs -->
<div class="bg-emerald-950/30 border border-emerald-800/50 rounded-xl p-4">
<p class="text-emerald-400 font-semibold mb-2">🟢 Points positifs</p>
{% if resultat.points_positifs %}
{% for p in resultat.points_positifs %}
<p class="text-emerald-300 text-sm flex items-start gap-2 mb-1"><i class="fa-solid fa-check text-emerald-500 mt-0.5"></i> {{ p }}</p>
{% endfor %}
{% else %}
<p class="text-gray-500 text-sm">Aucun point positif détecté.</p>
{% endif %}
</div>
<!-- Points négatifs -->
<div class="bg-rose-950/30 border border-rose-800/50 rounded-xl p-4">
<p class="text-rose-400 font-semibold mb-2">🔴 Points négatifs</p>
{% if resultat.points_negatifs %}
{% for n in resultat.points_negatifs %}
<p class="text-rose-300 text-sm flex items-start gap-2 mb-1"><i class="fa-solid fa-xmark text-rose-500 mt-0.5"></i> {{ n }}</p>
{% endfor %}
{% else %}
<p class="text-gray-500 text-sm">Aucun point négatif détecté.</p>
{% endif %}
</div>
</div>
<!-- Conclusion -->
<div class="mt-4 bg-gray-950 border border-gray-800 rounded-xl p-4 text-center">
<p class="text-gray-300 text-sm">
<strong>Conclusion :</strong>
Tendance
{% if resultat.tendance == 'haussiere' %}<span class="text-emerald-400 font-bold">positive</span>
{% elif resultat.tendance == 'baissiere' %}<span class="text-rose-400 font-bold">négative</span>
{% else %}<span class="text-amber-400 font-bold">neutre</span>{% endif %}
— Score {{ resultat.score }}/100.
{% if resultat.points_negatifs and resultat.points_positifs %}Des signaux contradictoires sont présents, prudence recommandée.{% endif %}
</p>
</div>
</div>
<!-- ===================== DÉTAILS : PERFORMANCES + MOYENNES + SÉRIES ===================== -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 w-full max-w-[98%] mb-4">
<!-- Performances -->
<div class="bg-gray-900 border border-gray-800 rounded-2xl p-4">
<h3 class="text-sm font-semibold text-gray-300 uppercase tracking-wider mb-3">Performances</h3>
<table class="w-full text-sm text-gray-300">
{% for fenetre, val in resultat.performances.fenetres.items() %}
<tr class="border-b border-gray-800/50">
<td class="py-1.5 px-2">{{ fenetre }}</td>
<td class="py-1.5 px-2 text-right font-bold {% if val and val >= 0 %}text-emerald-400{% elif val %}text-rose-400{% else %}text-gray-500{% endif %}">
{{ "{:+.2f}".format(val) if val is not none else '--' }}%
</td>
</tr>
{% endfor %}
{% if resultat.performances.ytd is not none %}
<tr class="border-b border-gray-800/50">
<td class="py-1.5 px-2">YTD</td>
<td class="py-1.5 px-2 text-right font-bold {% if resultat.performances.ytd >= 0 %}text-emerald-400{% else %}text-rose-400{% endif %}">{{ "{:+.2f}".format(resultat.performances.ytd) }}%</td>
</tr>
{% endif %}
</table>
</div>
<!-- Moyennes mobiles + Séries -->
<div class="bg-gray-900 border border-gray-800 rounded-2xl p-4">
<h3 class="text-sm font-semibold text-gray-300 uppercase tracking-wider mb-3">Moyennes mobiles & Séries</h3>
<table class="w-full text-sm text-gray-300 mb-3">
{% for mm, val in resultat.moyennes_mobiles.sma.items() %}
{% if mm.endswith('_distance') %}{% else %}
<tr class="border-b border-gray-800/50">
<td class="py-1.5 px-2">{{ mm }}</td>
<td class="py-1.5 px-2 text-right">{{ "{:,.2f}".format(val) if val is not none else '--' }}</td>
<td class="py-1.5 px-2 text-right text-xs {% if resultat.moyennes_mobiles.sma[mm + '_distance'] and resultat.moyennes_mobiles.sma[mm + '_distance'] >= 0 %}text-emerald-400{% elif resultat.moyennes_mobiles.sma[mm + '_distance'] %}text-rose-400{% else %}text-gray-500{% endif %}">
{{ "{:+.2f}".format(resultat.moyennes_mobiles.sma[mm + '_distance']) if resultat.moyennes_mobiles.sma.get(mm + '_distance') is not none else '' }}%
</td>
</tr>
{% endif %}
{% endfor %}
</table>
<div class="border-t border-gray-800 pt-3 space-y-1 text-xs text-gray-400">
<p>Hausse consécutive : <span class="text-emerald-400 font-bold">{{ resultat.series.jours_hausse_consecutifs }}j</span> · Baisse : <span class="text-rose-400 font-bold">{{ resultat.series.jours_baisse_consecutifs }}j</span></p>
<p>Plus forte hausse : <span class="text-emerald-400">{{ "{:+.2f}".format(resultat.series.plus_forte_hausse) if resultat.series.plus_forte_hausse is not none else '--' }}%</span> · Plus forte baisse : <span class="text-rose-400">{{ "{:.2f}".format(resultat.series.plus_forte_baisse) if resultat.series.plus_forte_baisse is not none else '--' }}%</span></p>
</div>
</div>
</div>
<!-- ===================== CHANDELIERS JAPONAIS ===================== -->
<div class="bg-gray-900 border border-gray-800 rounded-2xl shadow-2xl w-full max-w-[98%] p-4 mb-4">
<h3 class="text-sm font-semibold text-gray-300 uppercase tracking-wider mb-3"><i class="fa-solid fa-chart-simple text-blue-500"></i> Chandeliers japonais</h3>
<p class="text-gray-400 text-sm mb-2">Pattern actuel : <span class="text-amber-400 font-bold">{{ resultat.chandeliers.pattern_actuel }}</span></p>
{% if resultat.chandeliers.derniers_detectes %}
<table class="w-full text-sm text-gray-300">
<thead><tr class="text-gray-400 text-xs uppercase border-b border-gray-800"><th class="py-2 px-3 text-left">Date</th><th class="py-2 px-3 text-left">Pattern</th></tr></thead>
<tbody>
{% for c in resultat.chandeliers.derniers_detectes %}
<tr class="border-b border-gray-800/50"><td class="py-2 px-3">{{ c.date }}</td><td class="py-2 px-3 text-amber-400">{{ c.pattern }}</td></tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="text-gray-500 text-sm">Aucun pattern significatif détecté récemment.</p>
{% endif %}
</div>
<!-- Données JSON pour les graphiques (passées au JS) -->
<script id="data-cours" type="application/json">{{ resultat.graphique_cours | tojson | safe }}</script>
<script id="data-volumes" type="application/json">{{ resultat.graphique_volumes | tojson | safe }}</script>
<script id="data-volatilite" type="application/json">{{ resultat.graphique_volatilite | tojson | safe }}</script>
{% endif %}
{% else %}
<!-- Message d'accueil quand aucune recherche -->
<div class="bg-gray-900 border border-gray-800 rounded-2xl shadow-2xl w-full max-w-[98%] p-8 text-center">
<i class="fa-solid fa-chart-line text-gray-700 text-5xl mb-4"></i>
<h2 class="text-xl font-bold text-white mb-2">Analyse technique</h2>
<p class="text-gray-400 text-sm">Saisissez un ISIN ci-dessus pour afficher la fiche d'analyse complète.</p>
</div>
{% endif %}
</div>
<!-- Chart.js + charts.js -->
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
<script src="{{ url_for('static', filename='js/charts.js') }}"></script>
<script>
// Disparition des toasts
document.addEventListener("DOMContentLoaded", () => {
document.querySelectorAll(".toast-message").forEach(t => setTimeout(() => {
t.style.opacity = "0"; t.style.transform = "translateY(-10px)";
setTimeout(() => t.remove(), 300);
}, 4000));
});
</script>
{% endblock %}
+17 -13
View File
@@ -1,5 +1,5 @@
<!doctype html> <!doctype html>
<html lang="fr" class="dark"> <html lang="fr" class="dark h-full">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
@@ -14,18 +14,23 @@
<!-- Tailwind CSS CDN --> <!-- Tailwind CSS CDN -->
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script> <script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
<!-- Font Awesome -->
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/@fortawesome/fontawesome-free@6.5.2/css/all.min.css"
/>
</head> </head>
<body <body
class="bg-gray-950 text-gray-100 min-h-screen flex flex-col justify-between" class="bg-gray-950 text-gray-100 h-screen flex flex-col overflow-hidden"
> >
<!-- Header Global --> <!-- Header Global -->
<header <header
class="bg-gray-900 border-b border-gray-800 px-6 py-4 flex items-center justify-between" class="flex-none bg-gray-900 border-b border-gray-800 px-6 py-4 flex items-center justify-between z-10"
> >
<!-- Gauche : Logo (taille augmentée d'environ 1.5x, ex: h-10 -> h-16) --> <!-- Gauche : Logo -->
<div class="flex items-center"> <div class="flex items-center">
<a <a
href="{{ url_for('main.menu') if session.get('user_id') else url_for('main.index') }}" href="{{ url_for('menu.menu') if session.get('user_id') else url_for('auth.index') }}"
> >
<img <img
src="{{ url_for('static', filename='images/GFBlancLogo.png') }}" src="{{ url_for('static', filename='images/GFBlancLogo.png') }}"
@@ -35,7 +40,7 @@
</a> </a>
</div> </div>
<!-- Centre : Titre (agrandi avec text-4xl et police plus grasse) --> <!-- Centre : Titre -->
<div class="absolute left-1/2 transform -translate-x-1/2"> <div class="absolute left-1/2 transform -translate-x-1/2">
<h1 class="text-4xl font-extrabold tracking-wider text-white">Bolsa</h1> <h1 class="text-4xl font-extrabold tracking-wider text-white">Bolsa</h1>
</div> </div>
@@ -61,7 +66,7 @@
{% if session.get('user_id') %} {% if session.get('user_id') %}
<div class="pt-1.5 flex justify-end"> <div class="pt-1.5 flex justify-end">
<a <a
href="{{ url_for('main.logout') }}" href="{{ url_for('auth.logout') }}"
class="no-underline px-2.5 py-1 bg-red-950/60 hover:bg-red-900/80 border border-red-900/60 text-red-300 rounded text-[11px] font-sans transition duration-200" class="no-underline px-2.5 py-1 bg-red-950/60 hover:bg-red-900/80 border border-red-900/60 text-red-300 rounded text-[11px] font-sans transition duration-200"
> >
Se déconnecter Se déconnecter
@@ -72,13 +77,13 @@
</header> </header>
<!-- Container dynamique --> <!-- Container dynamique -->
<main class="flex-grow w-full px-2 py-4"> <main class="flex-1 w-full px-2 py-4 overflow-hidden flex flex-col">
{% block content %}{% endblock %} {% block content %}{% endblock %}
</main> </main>
<!-- Footer Global --> <!-- Footer Global -->
<footer <footer
class="bg-gray-900 border-t border-gray-800 px-6 py-4 flex items-center justify-between text-xs text-gray-400" class="flex-none bg-gray-900 border-t border-gray-800 px-6 py-4 flex items-center justify-between text-xs text-gray-400 z-10"
> >
<!-- Espace vide à gauche pour équilibrer le flexbox --> <!-- Espace vide à gauche pour équilibrer le flexbox -->
<div class="w-1/3"></div> <div class="w-1/3"></div>
@@ -101,7 +106,7 @@
</div> </div>
</footer> </footer>
<!-- Script pour récupérer l'IP externe en JS --> <!-- Script pour récupérer l'IP publique en JS -->
{% if session.get('user_id') %} {% if session.get('user_id') %}
<script> <script>
fetch("https://api.ipify.org?format=json") fetch("https://api.ipify.org?format=json")
@@ -109,9 +114,8 @@
.then((data) => { .then((data) => {
document.getElementById("external-ip").textContent = data.ip; document.getElementById("external-ip").textContent = data.ip;
}) })
.catch((error) => { .catch(() => {
document.getElementById("external-ip").textContent = document.getElementById("external-ip").textContent = "IP non disponible";
"IP non disponible";
}); });
</script> </script>
{% endif %} {% endif %}
+2 -1
View File
@@ -34,7 +34,7 @@ content %}
</p> </p>
</div> </div>
<a <a
href="{{ url_for('main.menu') }}" href="{{ url_for('menu.menu') }}"
class="px-4 py-2 bg-gray-800 hover:bg-gray-700 text-gray-200 text-sm font-medium rounded-lg transition duration-200" class="px-4 py-2 bg-gray-800 hover:bg-gray-700 text-gray-200 text-sm font-medium rounded-lg transition duration-200"
> >
Retour au menu Retour au menu
@@ -107,6 +107,7 @@ content %}
action="" action=""
class="space-y-4 flex-1 flex flex-col justify-between" class="space-y-4 flex-1 flex flex-col justify-between"
> >
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
<input type="hidden" id="field-id" name="id" /> <input type="hidden" id="field-id" name="id" />
<div class="space-y-4"> <div class="space-y-4">
+417
View File
@@ -0,0 +1,417 @@
{% extends "base.html" %} {% block title %}Gestion des Comptes - Bolsa{%
endblock %} {% block header_title %}Gestion des comptes{% endblock %} {% block
content %}
<div
class="flex flex-col items-center justify-center w-full h-full overflow-hidden px-2"
>
<!-- Système de Toasts -->
<div
id="toast-container"
class="fixed top-6 left-1/2 transform -translate-x-1/2 z-50 space-y-3 w-full max-w-md px-4 pointer-events-none"
>
{% set messages = get_flashed_messages(with_categories=true) %} {% if
messages %} {% for category, message in messages %}
<div
class="toast-message pointer-events-auto flex items-center justify-center px-4 py-3 rounded-xl shadow-2xl text-white text-sm font-medium transition-all duration-300 transform translate-y-0 opacity-100 text-center {% if category == 'success' %} bg-emerald-600 border border-emerald-500 {% elif category == 'warning' %} bg-amber-600 border border-amber-500 {% else %} bg-rose-600 border border-rose-500 {% endif %}"
>
<span>{{ message }}</span>
</div>
{% endfor %} {% endif %}
</div>
<!-- Box principale -->
<div
class="bg-gray-900 border border-gray-800 rounded-2xl shadow-2xl w-full max-w-[98%] h-full flex flex-col overflow-hidden"
>
<!-- Header fixe de la box -->
<div
class="p-4 sm:p-6 pb-3 border-b border-gray-800 flex justify-between items-center shrink-0"
>
<div>
<h2 class="text-2xl font-extrabold text-white flex items-center gap-2">
<i class="fa-solid fa-wallet text-blue-500"></i> Comptes -
<span class="text-amber-400">{{ selected_societe }}</span>
</h2>
<p class="text-gray-400 text-sm">
Suivi des écritures comptables de la société.
</p>
</div>
<div class="flex items-center gap-4">
<form
method="POST"
action="{{ url_for('comptes.gestion_comptes') }}"
class="m-0"
>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
<select
name="societe_id"
id="select-societe-id"
class="bg-gray-800 border border-gray-700 text-white text-sm rounded-lg px-3 py-2 focus:outline-none focus:border-blue-500 cursor-pointer"
onchange="this.form.submit()"
>
{% for soc in societes %} {% if soc.id == selected_societe_id %}
<option value="{{ soc.id }}" data-nom="{{ soc.nom }}" selected>
{{ soc.nom }}
</option>
{% else %}
<option value="{{ soc.id }}" data-nom="{{ soc.nom }}">
{{ soc.nom }}
</option>
{% endif %} {% endfor %}
</select>
</form>
<!-- Génération automatique des écritures depuis les ordres -->
<form
method="POST"
action="{{ url_for('comptes.generer_ecritures_ordres') }}"
class="m-0"
onsubmit="return confirm('Générer / régénérer les écritures comptables à partir des ordres de cette société ? Les écritures générées existantes seront remplacées.');"
>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
<input type="hidden" name="id_societe" value="{{ selected_societe_id }}" />
<button
type="submit"
class="px-4 py-2 bg-amber-600 hover:bg-amber-500 text-white text-sm font-medium rounded-lg transition duration-200 flex items-center gap-2 cursor-pointer"
title="Générer les écritures depuis les ordres"
>
<i class="fa-solid fa-wand-magic-sparkles"></i> Générer
</button>
</form>
<a
href="{{ url_for('menu.menu') }}"
class="px-4 py-2 bg-gray-800 hover:bg-gray-700 text-gray-200 text-sm font-medium rounded-lg transition duration-200"
>
Retour au menu
</a>
</div>
</div>
<!-- Corps scrollable -->
<div class="p-4 sm:p-6 overflow-y-auto flex-1">
<div
class="bg-gray-950 border border-gray-800 rounded-xl p-2 overflow-x-auto"
>
<table class="w-full text-left border-collapse text-sm">
<thead>
<tr
class="bg-blue-950/60 text-gray-400 uppercase tracking-wider text-xs border-b border-gray-800"
>
<th class="py-2 px-3">Date</th>
<th class="py-2 px-3">Libellé</th>
<th class="py-2 px-3 text-right">Crédit</th>
<th class="py-2 px-3 text-right">Débit</th>
<th class="py-2 px-3 text-end">
<button
type="button"
onclick="ouvrirModalCreation()"
class="px-3.5 py-1.5 bg-emerald-600 hover:bg-emerald-500 text-white text-xs font-semibold rounded shadow transition cursor-pointer"
>
Créer
</button>
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-800 text-gray-300">
{% if ecritures %} {% for e in ecritures %}
<tr class="hover:bg-gray-900/50 transition">
<td class="py-2 px-3 whitespace-nowrap">{{ e.date_operation }}</td>
<td class="py-2 px-3 text-gray-400">
{% if e.source == 'ordre' %}{{ e.libelle | safe }}{% else %}{{ e.libelle or '-' }}{% endif %}
</td>
<td class="py-2 px-3 text-right">
{% if e.credit and e.credit > 0 %} {{ "{:,.2f}".format(e.credit)
}} {% else %} - {% endif %}
</td>
<td class="py-2 px-3 text-right">
{% if e.debit and e.debit > 0 %} {{ "{:,.2f}".format(e.debit) }}
{% else %} - {% endif %}
</td>
<td class="py-2 px-3 text-end">
{% if e.source == 'manuel' %}
<button
type="button"
onclick="ouvrirModalModification('{{ e.id }}', '{{ e.date_operation }}', '{{ e.debit }}', '{{ e.credit }}', '{{ e.libelle | e }}')"
class="px-3.5 py-1.5 bg-blue-600 hover:bg-blue-500 text-white text-xs font-semibold rounded shadow transition inline-flex items-center justify-center cursor-pointer"
title="Modifier"
>
Modifier
</button>
{% else %}
<span
class="text-[10px] text-gray-600 italic uppercase tracking-wider"
title="Écriture générée automatiquement depuis un ordre"
>Auto</span
>
{% endif %}
</td>
</tr>
{% endfor %} {% else %}
<tr>
<td colspan="5" class="text-center text-gray-500 py-8">
Aucune écriture pour cette société.
</td>
</tr>
{% endif %}
<!-- Ligne TOTAL -->
<tr
class="bg-blue-950/60 text-white uppercase tracking-wider text-xs border-t border-b border-gray-800 font-bold"
>
<td class="py-2.5 px-3">TOTAL :</td>
<td class="py-2.5 px-3"></td>
<td class="py-2.5 px-3 text-right">
{{ "{:,.2f}".format(total_credit or 0) }}
</td>
<td class="py-2.5 px-3 text-right">
{{ "{:,.2f}".format(total_debit or 0) }}
</td>
<td class="py-2.5 px-3"></td>
</tr>
<!-- Ligne DISPO (récapitulatif financier) -->
<tr class="bg-blue-950 text-white border-b border-gray-800">
<td class="py-3 px-4" colspan="5">
<div class="flex flex-wrap items-center gap-x-6 gap-y-2 text-sm font-extrabold">
<span>
SOLDE :
<span class="ml-1 {% if solde >= 0 %}text-emerald-400{% else %}text-rose-400{% endif %}">
{{ "{:,.2f}".format(solde | abs) }} €{% if solde < 0 %} (-){% endif %}
</span>
</span>
<span class="text-gray-400">|</span>
<span>
DISPO :
<span class="ml-1 {% if dispo >= 0 %}text-emerald-400{% else %}text-rose-400{% endif %}">
{{ "{:,.2f}".format(dispo | abs) }} €{% if dispo < 0 %} (-){% endif %}
</span>
</span>
<span class="text-gray-400">|</span>
<span>
ENGA. :
<span class="ml-1 {% if engagement >= 0 %}text-emerald-400{% else %}text-rose-400{% endif %}">
{{ "{:,.2f}".format(engagement | abs) }} €{% if engagement < 0 %} (-){% endif %}
</span>
</span>
<span class="text-gray-400">|</span>
<span>
Depot :
<span class="ml-1 {% if tcredit >= 0 %}text-emerald-400{% else %}text-rose-400{% endif %}">
{{ "{:,.2f}".format(tcredit | abs) }} €{% if tcredit < 0 %} (-){% endif %}
</span>
</span>
<span class="text-gray-400">|</span>
<span>
Retrait :
<span class="ml-1 {% if tdebit >= 0 %}text-emerald-400{% else %}text-rose-400{% endif %}">
{{ "{:,.2f}".format(tdebit | abs) }} €{% if tdebit < 0 %} (-){% endif %}
</span>
</span>
<span class="text-gray-400">|</span>
<span>
PV :
<span class="ml-1 {% if pv >= 0 %}text-emerald-400{% else %}text-rose-400{% endif %}">
{{ "{:,.2f}".format(pv | abs) }} €{% if pv < 0 %} (-){% endif %}
</span>
</span>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Footer -->
<div
class="px-6 py-3 border-t border-gray-800 text-xs text-gray-400 text-center shrink-0 bg-gray-900"
>
Comptes : Accès sécurisé. Veillez à fermer votre session après chaque
utilisation.
</div>
</div>
</div>
<!-- Popup unique (Création et Modification d'écriture) -->
<div
id="modal-compte-ecriture"
class="fixed inset-0 bg-black/70 backdrop-blur-sm z-50 flex items-center justify-center hidden"
>
<div
class="bg-gray-900 border border-gray-800 rounded-2xl p-6 w-full max-w-lg shadow-2xl text-white relative"
>
<button
onclick="fermerModal()"
class="absolute top-4 right-4 text-gray-400 hover:text-white cursor-pointer"
>
<i class="fa-solid fa-xmark text-xl"></i>
</button>
<h3 id="modal-title" class="text-xl font-bold mb-4 flex items-center gap-2">
<i class="fa-solid fa-plus-circle text-emerald-500"></i> Nouvelle écriture
</h3>
<form
id="form-modal-ecriture"
method="POST"
action="{{ url_for('comptes.creer_compte_traitement') }}"
class="space-y-4"
>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
<input type="hidden" name="id_societe" id="input-id-societe" value="{{ selected_societe_id }}" />
<!-- Nom de la société (affichage en lecture seule) -->
<div>
<label class="text-xs text-gray-400">Société</label>
<input
type="text"
id="input-nom-societe"
readonly
class="w-full mt-1 bg-gray-950 border border-gray-800 rounded-lg px-3 py-2 text-sm text-amber-400 font-semibold focus:outline-none cursor-default"
/>
</div>
<!-- Date + Libellé -->
<div class="grid grid-cols-2 gap-3">
<div>
<label class="text-xs text-gray-400">Date d'opération</label>
<input
type="date"
name="date_operation"
id="input-date-operation"
required
class="w-full mt-1 bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:border-blue-500"
/>
</div>
<div>
<label class="text-xs text-gray-400">Libellé</label>
<input
type="text"
name="libelle"
id="input-libelle"
placeholder="Optionnel..."
class="w-full mt-1 bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:border-blue-500"
/>
</div>
</div>
<!-- Crédit + Débit -->
<div class="grid grid-cols-2 gap-3">
<div>
<label class="text-xs text-gray-400">Crédit (€)</label>
<input
type="number"
step="0.01"
name="credit"
id="input-credit"
value="0.00"
class="w-full mt-1 bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:border-blue-500"
/>
</div>
<div>
<label class="text-xs text-gray-400">Débit (€)</label>
<input
type="number"
step="0.01"
name="debit"
id="input-debit"
value="0.00"
class="w-full mt-1 bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:border-blue-500"
/>
</div>
</div>
<div class="flex justify-end gap-2 pt-2">
<button
type="button"
onclick="fermerModal()"
class="px-4 py-2 bg-gray-800 hover:bg-gray-700 text-gray-300 rounded-lg text-sm transition cursor-pointer"
>
Annuler
</button>
<button
type="submit"
id="btn-valider"
class="px-4 py-2 bg-emerald-600 hover:bg-emerald-500 text-white rounded-lg text-sm transition cursor-pointer"
>
Enregistrer
</button>
</div>
</form>
</div>
</div>
<script>
document.addEventListener("DOMContentLoaded", () => {
const toasts = document.querySelectorAll(".toast-message");
toasts.forEach((toast) => {
setTimeout(() => {
toast.style.opacity = "0";
toast.style.transform = "translateY(-10px)";
setTimeout(() => toast.remove(), 300);
}, 4000);
});
});
function getInfosSocieteActive() {
const select = document.getElementById("select-societe-id");
if (!select) return { id: "", nom: "" };
const selectedOption = select.options[select.selectedIndex];
return {
id: select.value,
nom: selectedOption ? selectedOption.getAttribute("data-nom") : "",
};
}
function ouvrirModalCreation() {
const modal = document.getElementById("modal-compte-ecriture");
const form = document.getElementById("form-modal-ecriture");
const title = document.getElementById("modal-title");
modal.classList.remove("hidden");
form.action = "{{ url_for('comptes.creer_compte_traitement') }}";
title.innerHTML =
'<i class="fa-solid fa-plus-circle text-emerald-500"></i> Nouvelle écriture';
const societe = getInfosSocieteActive();
document.getElementById("input-id-societe").value = societe.id;
document.getElementById("input-nom-societe").value = societe.nom;
document.getElementById("input-date-operation").value = "";
document.getElementById("input-libelle").value = "";
document.getElementById("input-credit").value = "0.00";
document.getElementById("input-debit").value = "0.00";
document.getElementById("btn-valider").textContent = "Enregistrer";
}
function ouvrirModalModification(id, dateOp, debit, credit, libelle) {
const modal = document.getElementById("modal-compte-ecriture");
const form = document.getElementById("form-modal-ecriture");
const title = document.getElementById("modal-title");
modal.classList.remove("hidden");
form.action = "/modifier_compte/" + id;
title.innerHTML =
'<i class="fa-solid fa-pen text-blue-500"></i> Modifier l\'écriture';
const societe = getInfosSocieteActive();
document.getElementById("input-id-societe").value = societe.id;
document.getElementById("input-nom-societe").value = societe.nom;
document.getElementById("input-date-operation").value = dateOp;
document.getElementById("input-libelle").value =
libelle === "None" ? "" : libelle;
document.getElementById("input-credit").value = credit;
document.getElementById("input-debit").value = debit;
document.getElementById("btn-valider").textContent = "Modifier";
}
function fermerModal() {
document.getElementById("modal-compte-ecriture").classList.add("hidden");
}
</script>
{% endblock %}
@@ -34,7 +34,7 @@ endblock %} {% block header_title %}Gestion CSV{% endblock %} {% block content
</p> </p>
</div> </div>
<a <a
href="{{ url_for('main.menu') }}" href="{{ url_for('menu.menu') }}"
class="px-4 py-2 bg-gray-800 hover:bg-gray-700 text-gray-200 text-sm font-medium rounded-lg transition duration-200" class="px-4 py-2 bg-gray-800 hover:bg-gray-700 text-gray-200 text-sm font-medium rounded-lg transition duration-200"
> >
Retour au menu Retour au menu
@@ -124,7 +124,7 @@ endblock %} {% block header_title %}Gestion CSV{% endblock %} {% block content
> >
<!-- Formulaire ou lien pour l'Export (réduit) --> <!-- Formulaire ou lien pour l'Export (réduit) -->
<a <a
href="{{ url_for('main.export_actions_csv') }}" href="{{ url_for('historique.export_actions_csv') }}"
class="w-full sm:w-1/4 flex items-center justify-center px-4 py-3 bg-blue-600 hover:bg-blue-500 text-white text-sm font-semibold rounded-xl shadow-lg transition duration-200" class="w-full sm:w-1/4 flex items-center justify-center px-4 py-3 bg-blue-600 hover:bg-blue-500 text-white text-sm font-semibold rounded-xl shadow-lg transition duration-200"
> >
<svg <svg
@@ -145,11 +145,12 @@ endblock %} {% block header_title %}Gestion CSV{% endblock %} {% block content
<!-- Formulaire pour l'Import (prend toute la place restante) --> <!-- Formulaire pour l'Import (prend toute la place restante) -->
<form <form
action="{{ url_for('main.import_actions_csv') }}" action="{{ url_for('historique.import_actions_csv') }}"
method="POST" method="POST"
enctype="multipart/form-data" enctype="multipart/form-data"
class="w-full sm:w-3/4 flex items-center gap-2" class="w-full sm:w-3/4 flex items-center gap-2"
> >
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
<input <input
type="file" type="file"
name="file" name="file"
@@ -0,0 +1,291 @@
{% extends "base.html" %} {% block title %}Import Historique CSV - Bolsa{% endblock
%} {% block header_title %}Import historique{% endblock %} {% block content %}
<div class="flex flex-col items-center justify-center py-6 relative">
<!-- Système de Toasts -->
<div
id="toast-container"
class="fixed top-6 left-1/2 transform -translate-x-1/2 z-50 space-y-3 w-full max-w-md px-4 pointer-events-none"
>
{% set messages = get_flashed_messages(with_categories=true) %} {% if messages
%} {% for category, message in messages %}
<div
class="toast-message pointer-events-auto flex items-center justify-center px-4 py-3 rounded-xl shadow-2xl text-white text-sm font-medium transition-all duration-300 transform translate-y-0 opacity-100 text-center {% if category == 'success' %} bg-emerald-600 border border-emerald-500 {% elif category == 'warning' %} bg-amber-600 border border-amber-500 {% else %} bg-rose-600 border border-rose-500 {% endif %}"
>
<span>{{ message }}</span>
</div>
{% endfor %} {% endif %}
</div>
<!-- Box centrée principale -->
<div
class="bg-gray-900 border border-gray-800 p-8 rounded-2xl shadow-2xl w-full max-w-3xl overflow-hidden"
>
<!-- 1. En-tête -->
<div
class="flex justify-between items-center px-2 py-2 mb-6 border-b border-gray-800 pb-4"
>
<div>
<h2 class="text-xl font-extrabold text-white flex items-center gap-2">
<i class="fa-solid fa-chart-line text-blue-500"></i> Import Historique
CSV
</h2>
<p class="text-gray-400 text-sm mt-0.5">
Import du cours historique d'une action (OHLCV) par ISIN.
</p>
</div>
<a
href="{{ url_for('menu.menu') }}"
class="px-4 py-2 bg-gray-800 hover:bg-gray-700 text-gray-200 text-sm font-medium rounded-lg transition duration-200"
>
Retour au menu
</a>
</div>
<!-- 2. Corps : Box d'importation -->
<form
id="form-import-historique"
action="{{ url_for('historique.import_historique_csv') }}"
method="POST"
enctype="multipart/form-data"
class="space-y-6"
>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
<div
class="bg-gray-950 border border-gray-800 rounded-xl p-6 space-y-5"
>
<h3
class="text-sm font-semibold text-gray-200 uppercase tracking-wider"
>
Paramètres d'importation
</h3>
<!-- 1. ISIN + vérification dynamique -->
<div>
<label class="block text-xs font-medium text-gray-400 mb-2"
>Code ISIN de l'action</label
>
<div class="flex gap-2">
<input
type="text"
id="input-isin"
name="isin"
required
placeholder="Ex : NL00150001Q9"
class="flex-1 bg-gray-900 border border-gray-800 rounded-lg px-3 py-2.5 text-white text-sm focus:outline-none focus:border-blue-500 uppercase"
/>
<button
type="button"
id="btn-verifier-isin"
onclick="verifierIsin()"
class="px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white text-sm font-medium rounded-lg transition cursor-pointer"
>
<i class="fa-solid fa-check"></i> Vérifier
</button>
</div>
<p id="isin-status" class="text-xs mt-2"></p>
</div>
<!-- 2. Nom de l'action (lecture seule, affiché après vérification) -->
<div>
<label class="block text-xs font-medium text-gray-400 mb-2"
>Nom de l'action</label
>
<div
class="flex items-center bg-gray-950 border border-gray-800 rounded-lg px-3 py-2.5"
>
<input
type="text"
id="input-company-name"
readonly
placeholder="Sera affiché après vérification de l'ISIN..."
class="flex-1 bg-transparent text-amber-400 font-semibold text-sm focus:outline-none cursor-default"
/>
<div
id="action-info"
class="hidden text-xs text-gray-300 ml-3 flex items-center gap-3"
>
<span><strong>Ticker :</strong> <span id="lbl-ticker"></span></span>
<span class="text-gray-600">|</span>
<span><strong>Devise :</strong> <span id="lbl-currency"></span></span>
</div>
</div>
</div>
<!-- 3. Sélection du fichier CSV -->
<div>
<label class="block text-xs font-medium text-gray-400 mb-2"
>Fichier CSV historique</label
>
<input
type="file"
name="file"
accept=".csv"
id="input-file"
required
class="block w-full text-xs text-gray-400 file:mr-4 file:py-3 file:px-4 file:rounded-xl file:border-0 file:text-sm file:font-semibold file:bg-blue-600 file:text-white hover:file:bg-blue-500 file:cursor-pointer cursor-pointer bg-gray-900 border border-gray-800 rounded-xl"
/>
</div>
<!-- Note sur le format attendu -->
<div
class="bg-amber-500/10 border border-amber-500/30 rounded-lg p-3 text-amber-300 text-xs flex items-start space-x-2"
>
<i class="fa-solid fa-circle-info text-amber-400 mt-0.5"></i>
<span>
Format CSV attendu (en-tête obligatoire, séparateur virgule) :
<code
class="text-amber-200 bg-gray-900 px-1.5 py-0.5 rounded font-mono"
>date,price,open,hight,low,vol,change</code
>. La colonne <code class="font-mono">vol</code> accepte les suffixes
<code class="font-mono">K</code> (x1000) et
<code class="font-mono">M</code> (x1 000 000). Les doublons (même
ISIN + même date) sont ignorés automatiquement.
</span>
</div>
</div>
<!-- 4. Bouton de validation -->
<div class="flex items-center justify-end gap-3">
<button
type="button"
onclick="reinitialiser()"
class="px-4 py-2.5 bg-gray-800 hover:bg-gray-700 text-gray-300 text-sm font-medium rounded-xl transition"
>
<i class="fa-solid fa-rotate-left"></i> Réinitialiser
</button>
<button
type="submit"
id="btn-valider"
disabled
class="px-5 py-2.5 bg-emerald-600/50 cursor-not-allowed text-white text-sm font-semibold rounded-xl shadow-lg transition"
>
<i class="fa-solid fa-upload"></i> Importer le fichier
</button>
</div>
</form>
<!-- 3. Pied de boîte -->
<div
class="px-2 py-4 mt-6 text-xs text-gray-400 italic text-center"
>
Module d'import historique sécurisé — Giraud Finance - 2026
</div>
</div>
</div>
<script>
// Disparition automatique des Toasts au bout de 4 secondes
document.addEventListener("DOMContentLoaded", () => {
const toasts = document.querySelectorAll(".toast-message");
toasts.forEach((toast) => {
setTimeout(() => {
toast.style.opacity = "0";
toast.style.transform = "translateY(10px)";
setTimeout(() => toast.remove(), 300);
}, 4000);
});
});
// Vérification dynamique de l'ISIN
function verifierIsin() {
const isin = document.getElementById("input-isin").value.trim();
const statusEl = document.getElementById("isin-status");
const infoBox = document.getElementById("action-info");
const companyInput = document.getElementById("input-company-name");
const btnValider = document.getElementById("btn-valider");
if (!isin) {
statusEl.textContent = "Veuillez saisir un code ISIN.";
statusEl.className = "text-xs mt-2 text-rose-400";
btnValider.disabled = true;
btnValider.className =
"px-5 py-2.5 bg-emerald-600/50 cursor-not-allowed text-white text-sm font-semibold rounded-xl shadow-lg transition";
return;
}
fetch("/api/verifier_isin", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ isin: isin }),
})
.then((response) => response.json())
.then((data) => {
if (data.exists) {
statusEl.innerHTML =
'<i class="fa-solid fa-circle-check"></i> ISIN reconnu dans la base.';
statusEl.className = "text-xs mt-2 text-emerald-400";
companyInput.value = data.company_name || "";
document.getElementById("lbl-ticker").textContent =
data.ticker || "N/A";
document.getElementById("lbl-currency").textContent =
data.currency || "N/A";
infoBox.classList.remove("hidden");
btnValider.disabled = false;
btnValider.className =
"px-5 py-2.5 bg-emerald-600 hover:bg-emerald-500 text-white text-sm font-semibold rounded-xl shadow-lg transition cursor-pointer";
} else {
statusEl.innerHTML =
'<i class="fa-solid fa-circle-xmark"></i> Cet ISIN n\'existe pas dans la base actions.';
statusEl.className = "text-xs mt-2 text-rose-400";
companyInput.value = "";
infoBox.classList.add("hidden");
btnValider.disabled = true;
btnValider.className =
"px-5 py-2.5 bg-emerald-600/50 cursor-not-allowed text-white text-sm font-semibold rounded-xl shadow-lg transition";
}
})
.catch(() => {
statusEl.textContent = "Erreur de communication avec le serveur.";
statusEl.className = "text-xs mt-2 text-rose-400";
});
}
// Validation du formulaire : ISIN vérifié + fichier sélectionné
document
.getElementById("input-file")
.addEventListener("change", verifierBoutonValider);
function verifierBoutonValider() {
const isinOk =
document.getElementById("input-company-name").value !== "";
const fichierOk =
document.getElementById("input-file").value !== "";
const btnValider = document.getElementById("btn-valider");
if (isinOk && fichierOk) {
btnValider.disabled = false;
btnValider.className =
"px-5 py-2.5 bg-emerald-600 hover:bg-emerald-500 text-white text-sm font-semibold rounded-xl shadow-lg transition cursor-pointer";
} else {
btnValider.disabled = true;
btnValider.className =
"px-5 py-2.5 bg-emerald-600/50 cursor-not-allowed text-white text-sm font-semibold rounded-xl shadow-lg transition";
}
}
function reinitialiser() {
document.getElementById("form-import-historique").reset();
document.getElementById("isin-status").textContent = "";
document.getElementById("action-info").classList.add("hidden");
const btnValider = document.getElementById("btn-valider");
btnValider.disabled = true;
btnValider.className =
"px-5 py-2.5 bg-emerald-600/50 cursor-not-allowed text-white text-sm font-semibold rounded-xl shadow-lg transition";
}
// Toujours valider avant soumission (sécurité supplémentaire)
document
.getElementById("form-import-historique")
.addEventListener("submit", (e) => {
const isinOk =
document.getElementById("input-company-name").value !== "";
if (!isinOk) {
e.preventDefault();
alert("Veuillez vérifier l'ISIN avant d'importer.");
}
});
</script>
{% endblock %}
+167 -144
View File
@@ -1,7 +1,7 @@
{% extends "base.html" %} {% block title %}Gestion des Ordres - Bolsa{% endblock {% extends "base.html" %} {% block title %}Gestion des Ordres - Bolsa{% endblock
%} {% block header_title %}Gestion des ordres{% endblock %} {% block content %} %} {% block header_title %}Gestion des ordres{% endblock %} {% block content %}
<div <div
class="flex flex-col items-center justify-center py-2 px-2 relative w-full" class="flex flex-col items-center justify-center w-full h-full overflow-hidden px-2"
> >
<!-- Système de Toasts --> <!-- Système de Toasts -->
<div <div
@@ -18,12 +18,13 @@
{% endfor %} {% endif %} {% endfor %} {% endif %}
</div> </div>
<!-- Box principale --> <!-- Box principale : Hauteur 100% du conteneur parent (qui occupe tout l'espace libre) -->
<div <div
class="bg-gray-900 border border-gray-800 p-4 sm:p-6 rounded-2xl shadow-2xl w-full max-w-[98%]" class="bg-gray-900 border border-gray-800 rounded-2xl shadow-2xl w-full max-w-[98%] h-full flex flex-col overflow-hidden"
> >
<!-- Header fixe de la box -->
<div <div
class="flex justify-between items-center mb-4 border-b border-gray-800 pb-3" class="p-4 sm:p-6 pb-3 border-b border-gray-800 flex justify-between items-center shrink-0"
> >
<div> <div>
<h2 class="text-2xl font-extrabold text-white flex items-center gap-2"> <h2 class="text-2xl font-extrabold text-white flex items-center gap-2">
@@ -40,9 +41,10 @@
<!-- Formulaire combiné / Filtre Année + Société --> <!-- Formulaire combiné / Filtre Année + Société -->
<form <form
method="POST" method="POST"
action="{{ url_for('main.gestion_ordres') }}" action="{{ url_for('ordres.gestion_ordres') }}"
class="flex items-center gap-2 m-0" class="flex items-center gap-2 m-0"
> >
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
<!-- Conserver la société active lors du changement d'année --> <!-- Conserver la société active lors du changement d'année -->
<input <input
type="hidden" type="hidden"
@@ -71,16 +73,17 @@
<span <span
class="font-bold {% if total_plus_value_globale >= 0 %}text-emerald-400{% else %}text-rose-400{% endif %}" class="font-bold {% if total_plus_value_globale >= 0 %}text-emerald-400{% else %}text-rose-400{% endif %}"
> >
{{ "{:,.2f}".format(total_plus_value_globale or 0) }} € {{ "{:,.2f}".format(total_plus_value_globale | abs) }} €
</span> </span>
</div> </div>
</form> </form>
<form <form
method="POST" method="POST"
action="{{ url_for('main.gestion_ordres') }}" action="{{ url_for('ordres.gestion_ordres') }}"
class="m-0" class="m-0"
> >
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
<!-- Conserver l'année active lors du changement de société --> <!-- Conserver l'année active lors du changement de société -->
<input <input
type="hidden" type="hidden"
@@ -93,9 +96,9 @@
onchange="this.form.submit()" onchange="this.form.submit()"
> >
{% for soc in societes %} {% if soc.id == selected_societe_id %} {% for soc in societes %} {% if soc.id == selected_societe_id %}
<option value="{{ soc.id }}" selected>{{ soc.Nom }}</option> <option value="{{ soc.id }}" selected>{{ soc.nom }}</option>
{% else %} {% else %}
<option value="{{ soc.id }}">{{ soc.Nom }}</option> <option value="{{ soc.id }}">{{ soc.nom }}</option>
{% endif %} {% endfor %} {% endif %} {% endfor %}
</select> </select>
</form> </form>
@@ -109,7 +112,7 @@
</button> </button>
<a <a
href="{{ url_for('main.menu') }}" href="{{ url_for('menu.menu') }}"
class="px-4 py-2 bg-gray-800 hover:bg-gray-700 text-gray-200 text-sm font-medium rounded-lg transition duration-200" class="px-4 py-2 bg-gray-800 hover:bg-gray-700 text-gray-200 text-sm font-medium rounded-lg transition duration-200"
> >
Retour au menu Retour au menu
@@ -117,144 +120,163 @@
</div> </div>
</div> </div>
<!-- Tableau --> <!-- Corps scrollable de la box (flex-1 et overflow-y-auto pour isoler le défilement) -->
<div <div class="p-4 sm:p-6 overflow-y-auto flex-1">
class="bg-gray-950 border border-gray-800 rounded-xl p-2 overflow-x-auto" <!-- Tableau -->
> <div
<table class="w-full text-left border-collapse text-sm"> class="bg-gray-950 border border-gray-800 rounded-xl p-2 overflow-x-auto"
<tbody class="divide-y divide-gray-800 text-gray-300"> >
{% if groupes_ordres %} {% for groupe in groupes_ordres %} <table class="w-full text-left border-collapse text-sm">
<tr class="bg-blue-950 text-white font-semibold"> <tbody class="divide-y divide-gray-800 text-gray-300">
<td colspan="12" class="py-2.5 px-3 text-base"> {% if groupes_ordres %} {% for groupe in groupes_ordres %}
{{ groupe.isin }} --- {{ groupe.company_name }} ( {{ groupe.ticker <tr class="bg-blue-950 text-white font-semibold">
}} ) <td colspan="12" class="py-2.5 px-3 text-base">
</td> {{ groupe.isin }} --- {{ groupe.company_name }} ( {{
</tr> groupe.ticker }} )
</td>
</tr>
<tr <tr
class="bg-blue-950/60 text-gray-400 uppercase tracking-wider text-xs border-t border-b border-gray-800" class="bg-blue-950/60 text-gray-400 uppercase tracking-wider text-xs border-t border-b border-gray-800"
> >
<th class="py-2 px-3">Date</th> <th class="py-2 px-3">Date</th>
<th class="py-2 px-3">Qté</th> <th class="py-2 px-3">Qté</th>
<th class="py-2 px-3">Prix Brut</th> <th class="py-2 px-3">Prix Brut</th>
<th class="py-2 px-3">Frais</th> <th class="py-2 px-3">Frais</th>
<th class="py-2 px-3">Prix Net</th> <th class="py-2 px-3">Prix Net</th>
<th class="py-2 px-3">Eng. Brut</th> <th class="py-2 px-3">Eng. Brut</th>
<th class="py-2 px-3">Eng. Net</th> <th class="py-2 px-3">Eng. Net</th>
<th class="py-2 px-3">Ordre</th> <th class="py-2 px-3">Ordre</th>
<th class="py-2 px-3">Type</th> <th class="py-2 px-3">Type</th>
<th class="py-2 px-3">État</th> <th class="py-2 px-3">État</th>
<th class="py-2 px-3 text-end"> <th class="py-2 px-3 text-end">
<button <button
type="button" type="button"
onclick="ouvrirModalLigneOrdre('{{ groupe.id }}', '{{ groupe.isin }}', '{{ groupe.company_name }}', '{{ groupe.ticker }}')" onclick="ouvrirModalLigneOrdre('{{ groupe.id }}', '{{ groupe.isin }}', '{{ groupe.company_name }}', '{{ groupe.ticker }}')"
class="px-3.5 py-1.5 bg-emerald-600 hover:bg-emerald-500 text-white text-xs font-semibold rounded shadow transition cursor-pointer" class="px-3.5 py-1.5 bg-emerald-600 hover:bg-emerald-500 text-white text-xs font-semibold rounded shadow transition cursor-pointer"
> >
Créer Créer
</button> </button>
</th> </th>
</tr> </tr>
{% for p in groupe.pieds %} {% for p in groupe.pieds %}
<tr class="hover:bg-gray-900/50 transition"> <tr class="hover:bg-gray-900/50 transition">
<td class="py-2 px-3 whitespace-nowrap">{{ p.date_op }}</td> <td class="py-2 px-3 whitespace-nowrap">{{ p.date_op }}</td>
<td class="py-2 px-3"> <td class="py-2 px-3">
{{ "{:,.0f}".format(p.quantite).replace(",", " ") }} {{ "{:,.0f}".format(p.quantite).replace(",", " ") }}
</td> </td>
<td class="py-2 px-3">{{ "{:,.3f}".format(p.prix_brut) }}</td> <td class="py-2 px-3">
<td class="py-2 px-3">{{ "{:,.3f}".format(p.frais) }}</td> {{ "{:,.3f}".format(p.prix_brut | abs) }}
<td class="py-2 px-3">{{ "{:,.3f}".format(p.prix_net) }}</td> </td>
<td class="py-2 px-3">{{ "{:,.3f}".format(p.eng_brut) }}</td> <td class="py-2 px-3">{{ "{:,.3f}".format(p.frais | abs) }}</td>
<td class="py-2 px-3">{{ "{:,.3f}".format(p.eng_net) }}</td> <td class="py-2 px-3">
<td class="py-2 px-3"> {{ "{:,.3f}".format(p.prix_net | abs) }}
<span </td>
class="px-2 py-0.5 rounded text-xs font-semibold {% if p.ordre == 'achat' %}bg-blue-900 text-blue-200{% else %}bg-rose-900 text-rose-200{% endif %}" <td class="py-2 px-3">{{ "{:,.3f}".format(p.eng_brut) }}</td>
> <td class="py-2 px-3">{{ "{:,.3f}".format(p.eng_net) }}</td>
{{ p.ordre | upper }} <td class="py-2 px-3">
</span> <span
</td> class="px-2 py-0.5 rounded text-xs font-semibold {% if p.ordre == 'achat' %}bg-blue-900 text-blue-200{% else %}bg-rose-900 text-rose-200{% endif %}"
<td class="py-2 px-3">{{ p.type_ordre }}</td> >
<td class="py-2 px-3"> {{ p.ordre | upper }}
<span </span>
class="px-2 py-0.5 rounded text-xs font-medium {% if p.etat == 'actif' %}bg-emerald-900 text-emerald-200 {% elif p.etat == 'soldé' %}bg-gray-800 text-gray-300 {% else %}bg-amber-900 text-amber-200{% endif %}" </td>
> <td class="py-2 px-3">{{ p.type_ordre }}</td>
{{ p.etat }} <td class="py-2 px-3">
</span> <span
</td> class="px-2 py-0.5 rounded text-xs font-medium {% if p.etat == 'actif' %}bg-emerald-900 text-emerald-200 {% elif p.etat == 'soldé' %}bg-gray-800 text-gray-300 {% else %}bg-amber-900 text-amber-200{% endif %}"
<td class="py-2 px-3 text-end"> >
<button {{ p.etat }}
type="button" </span>
onclick="ouvrirModalModifierLigne('{{ p.id }}', '{{ groupe.isin }}', '{{ groupe.company_name }}', '{{ groupe.ticker }}', '{{ p.date_op }}', '{{ p.quantite }}', '{{ p.prix_brut }}', '{{ p.frais_brocker }}', '{{ p.frais_etat }}', '{{ p.frais_autre }}', '{{ p.ordre }}', '{{ p.type_ordre }}', '{{ p.etat }}')" </td>
class="px-3.5 py-1.5 bg-blue-600 hover:bg-blue-500 text-white text-xs font-semibold rounded shadow transition inline-flex items-center justify-center cursor-pointer" <td class="py-2 px-3 text-end">
title="Modifier" <button
> type="button"
Modifier onclick="ouvrirModalModifierLigne('{{ p.id }}', '{{ groupe.isin }}', '{{ groupe.company_name }}', '{{ groupe.ticker }}', '{{ p.date_op }}', '{{ p.quantite }}', '{{ p.prix_brut }}', '{{ p.frais_broker }}', '{{ p.frais_etat }}', '{{ p.frais_autre }}', '{{ p.ordre }}', '{{ p.type_ordre }}', '{{ p.etat }}')"
</button> class="px-3.5 py-1.5 bg-blue-600 hover:bg-blue-500 text-white text-xs font-semibold rounded shadow transition inline-flex items-center justify-center cursor-pointer"
</td> title="Modifier"
</tr> >
{% endfor %} Modifier
</button>
</td>
</tr>
{% endfor %}
<!-- LIGNE TOTAL --> <!-- LIGNE TOTAL -->
<tr <tr
class="bg-blue-950/60 text-white uppercase tracking-wider text-xs border-t border-b border-gray-800 font-bold" class="bg-blue-950/60 text-white uppercase tracking-wider text-xs border-t border-b border-gray-800 font-bold"
> >
<td class="py-2.5 px-3">TOTAL :</td> <td class="py-2.5 px-3">TOTAL :</td>
<td class="py-2.5 px-3"> <td class="py-2.5 px-3">
{{ "{:,.0f}".format(groupe.total_quantite).replace(",", " ") }} {{ "{:,.0f}".format(groupe.total_quantite).replace(",", " ") }}
</td> </td>
{% if groupe.total_quantite != 0 %} {% set total_prix_net_moyen = {% if groupe.total_quantite != 0 %} {% set total_prix_net_moyen =
(groupe.total_eng_net / groupe.total_quantite) | abs %} {% set (groupe.total_eng_net / groupe.total_quantite) %} {% set
total_prix_brut_moyen = (groupe.total_eng_brut / total_prix_brut_moyen = (groupe.total_eng_brut /
groupe.total_quantite) | abs %} {% set total_frais_moyen = groupe.total_quantite) %} {% set total_frais_moyen =
(total_prix_net_moyen - total_prix_brut_moyen) | abs %} (total_prix_net_moyen - total_prix_brut_moyen) %}
<td class="py-2.5 px-3"> <td class="py-2.5 px-3">
{{ "{:,.3f}".format(total_prix_brut_moyen) }} {{ "{:,.3f}".format(total_prix_brut_moyen | abs) }}
</td> </td>
<td class="py-2.5 px-3"> <td class="py-2.5 px-3">
{{ "{:,.3f}".format(total_frais_moyen) }} {{ "{:,.3f}".format(total_frais_moyen | abs) }}
</td> </td>
<td class="py-2.5 px-3"> <td class="py-2.5 px-3">
{{ "{:,.3f}".format(total_prix_net_moyen) }} {{ "{:,.3f}".format(total_prix_net_moyen | abs) }}
</td> </td>
<td class="py-2.5 px-3"> <td class="py-2.5 px-3">
{{ "{:,.3f}".format(groupe.total_eng_brut | abs) }} {{ "{:,.3f}".format(groupe.total_eng_brut) }}
</td> </td>
<td class="py-2.5 px-3"> <td class="py-2.5 px-3">
{{ "{:,.3f}".format(groupe.total_eng_net | abs) }} <span
</td> class="{% if groupe.total_eng_net >= 0 %}text-emerald-400{% else %}text-rose-400{% endif %}"
{% else %} >
<td class="py-2.5 px-3">-</td> {{ "{:,.3f}".format(groupe.total_eng_net) }}
<td class="py-2.5 px-3">-</td> </span>
<td class="py-2.5 px-3">-</td> </td>
<td class="py-2.5 px-3"> {% else %}
{{ "{:,.3f}".format(groupe.total_eng_brut | abs) }} <td class="py-2.5 px-3">-</td>
</td> <td class="py-2.5 px-3">-</td>
<td class="py-2.5 px-3"> <td class="py-2.5 px-3">-</td>
<span <td class="py-2.5 px-3">
class="{% if groupe.total_eng_net >= 0 %}text-emerald-400{% else %}text-rose-400{% endif %}" {{ "{:,.3f}".format(groupe.total_eng_brut) }}
> </td>
{{ "{:,.3f}".format(groupe.total_eng_net | abs) }} <td class="py-2.5 px-3">
</span> <span
</td> class="{% if groupe.total_eng_net >= 0 %}text-emerald-400{% else %}text-rose-400{% endif %}"
>
{{ "{:,.3f}".format(groupe.total_eng_net) }}
</span>
</td>
{% endif %}
<td class="py-2.5 px-3" colspan="4"></td>
</tr>
<tr>
<td colspan="11" class="h-3 bg-transparent border-0"></td>
</tr>
{% endfor %} {% else %}
<tr>
<td colspan="11" class="text-center text-gray-500 py-8">
Aucun ordre trouvé pour cette société.
</td>
</tr>
{% endif %} {% endif %}
</tbody>
</table>
</div>
</div>
<td class="py-2.5 px-3" colspan="4"></td> <!-- Footer fixe de la box -->
</tr> <div
class="px-6 py-3 border-t border-gray-800 text-xs text-gray-400 text-center shrink-0 bg-gray-900"
<tr> >
<td colspan="11" class="h-3 bg-transparent border-0"></td> Positions en bourse : Accès sécurisé. Veillez à fermer votre session après
</tr> chaque utilisation.
{% endfor %} {% else %}
<tr>
<td colspan="11" class="text-center text-gray-500 py-8">
Aucun ordre trouvé pour cette société.
</td>
</tr>
{% endif %}
</tbody>
</table>
</div> </div>
</div> </div>
</div> </div>
@@ -282,9 +304,10 @@
<form <form
id="form-modal-ordre" id="form-modal-ordre"
method="POST" method="POST"
action="{{ url_for('main.creer_ordre_traitement') }}" action="{{ url_for('ordres.creer_ordre_traitement') }}"
class="space-y-4" class="space-y-4"
> >
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
<!-- Transmettre dynamiquement l'année active dans le formulaire de la modale --> <!-- Transmettre dynamiquement l'année active dans le formulaire de la modale -->
<input <input
type="hidden" type="hidden"
@@ -385,7 +408,7 @@
type="number" type="number"
step="0.0001" step="0.0001"
id="input-frais-brocker" id="input-frais-brocker"
name="frais_brocker" name="frais_broker"
value="0.0000" value="0.0000"
oninput="calculerTotauxModal()" oninput="calculerTotauxModal()"
class="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:border-blue-500" class="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:border-blue-500"
@@ -576,7 +599,7 @@
const btnValider = document.getElementById("btn-valider"); const btnValider = document.getElementById("btn-valider");
modal.classList.remove("hidden"); modal.classList.remove("hidden");
form.action = "{{ url_for('main.creer_ordre_traitement') }}"; form.action = "{{ url_for('ordres.creer_ordre_traitement') }}";
title.innerHTML = title.innerHTML =
'<i class="fa-solid fa-plus-circle text-emerald-500"></i> Créer un Nouvel Ordre'; '<i class="fa-solid fa-plus-circle text-emerald-500"></i> Créer un Nouvel Ordre';
+15 -14
View File
@@ -34,7 +34,7 @@ content %}
</p> </p>
</div> </div>
<a <a
href="{{ url_for('main.menu') }}" href="{{ url_for('menu.menu') }}"
class="px-4 py-2 bg-gray-800 hover:bg-gray-700 text-gray-200 text-sm font-medium rounded-lg transition duration-200" class="px-4 py-2 bg-gray-800 hover:bg-gray-700 text-gray-200 text-sm font-medium rounded-lg transition duration-200"
> >
Retour au menu Retour au menu
@@ -57,15 +57,15 @@ content %}
<div class="overflow-y-auto flex-1 space-y-1 pr-1"> <div class="overflow-y-auto flex-1 space-y-1 pr-1">
{% for s in societes %} {% for s in societes %}
<div <div
onclick="selectionnerSociete('{{ s.id }}', '{{ s.Nom | e }}', '{{ s.Forme | e }}', '{{ s.Capital }}', '{{ s.siren }}', '{{ s.siret }}', '{{ s.RCS | e }}', '{{ s.TVA | e }}', '{{ s.NAF | e }}', '{{ s.Tel | e }}', '{{ s.Web | e }}', '{{ s.Mail | e }}')" onclick="selectionnerSociete('{{ s.id }}', '{{ s.nom | e }}', '{{ s.forme | e }}', '{{ s.capital }}', '{{ s.siren }}', '{{ s.siret }}', '{{ s.rcs | e }}', '{{ s.tva | e }}', '{{ s.naf | e }}', '{{ s.tel | e }}', '{{ s.web | e }}', '{{ s.mail | e }}')"
class="societe-item cursor-pointer px-3 py-2.5 rounded-lg {% if loop.first %}bg-blue-600 text-white{% else %}hover:bg-gray-800 text-gray-300{% endif %} font-medium text-sm transition flex justify-between items-center" class="societe-item cursor-pointer px-3 py-2.5 rounded-lg {% if loop.first %}bg-blue-600 text-white{% else %}hover:bg-gray-800 text-gray-300{% endif %} font-medium text-sm transition flex justify-between items-center"
data-id="{{ s.id }}" data-id="{{ s.id }}"
> >
<span class="truncate pr-2 font-semibold">{{ s.Nom }}</span> <span class="truncate pr-2 font-semibold">{{ s.nom }}</span>
<span <span
class="text-xs {% if loop.first %}bg-blue-800{% else %}bg-gray-800 text-gray-400{% endif %} px-2 py-0.5 rounded whitespace-nowrap" class="text-xs {% if loop.first %}bg-blue-800{% else %}bg-gray-800 text-gray-400{% endif %} px-2 py-0.5 rounded whitespace-nowrap"
> >
{{ s.Forme or 'N/A' }} {{ s.forme or 'N/A' }}
</span> </span>
</div> </div>
{% endfor %} {% endfor %}
@@ -82,7 +82,8 @@ content %}
action="" action=""
class="space-y-4 flex-1 flex flex-col justify-between" class="space-y-4 flex-1 flex flex-col justify-between"
> >
<!-- ID technique caché --> <!-- Jeton CSRF + ID technique caché -->
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
<input type="hidden" id="field-id" name="id" /> <input type="hidden" id="field-id" name="id" />
<div class="space-y-4"> <div class="space-y-4">
@@ -101,7 +102,7 @@ content %}
<input <input
type="text" type="text"
id="field-nom" id="field-nom"
name="Nom" name="nom"
required required
maxlength="60" maxlength="60"
class="w-full bg-gray-900 border border-gray-800 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-blue-500" class="w-full bg-gray-900 border border-gray-800 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-blue-500"
@@ -114,7 +115,7 @@ content %}
<input <input
type="text" type="text"
id="field-forme" id="field-forme"
name="Forme" name="forme"
required required
maxlength="10" maxlength="10"
class="w-full bg-gray-900 border border-gray-800 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-blue-500" class="w-full bg-gray-900 border border-gray-800 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-blue-500"
@@ -127,7 +128,7 @@ content %}
<input <input
type="number" type="number"
id="field-capital" id="field-capital"
name="Capital" name="capital"
required required
class="w-full bg-gray-900 border border-gray-800 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-blue-500" class="w-full bg-gray-900 border border-gray-800 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-blue-500"
/> />
@@ -174,7 +175,7 @@ content %}
<input <input
type="text" type="text"
id="field-rcs" id="field-rcs"
name="RCS" name="rcs"
required required
maxlength="50" maxlength="50"
class="w-full bg-gray-900 border border-gray-800 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-blue-500" class="w-full bg-gray-900 border border-gray-800 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-blue-500"
@@ -189,7 +190,7 @@ content %}
<input <input
type="text" type="text"
id="field-tva" id="field-tva"
name="TVA" name="tva"
required required
maxlength="50" maxlength="50"
class="w-full bg-gray-900 border border-gray-800 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-blue-500" class="w-full bg-gray-900 border border-gray-800 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-blue-500"
@@ -204,7 +205,7 @@ content %}
<input <input
type="text" type="text"
id="field-naf" id="field-naf"
name="NAF" name="naf"
required required
maxlength="10" maxlength="10"
class="w-full bg-gray-900 border border-gray-800 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-blue-500" class="w-full bg-gray-900 border border-gray-800 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-blue-500"
@@ -219,7 +220,7 @@ content %}
<input <input
type="text" type="text"
id="field-tel" id="field-tel"
name="Tel" name="tel"
required required
maxlength="30" maxlength="30"
class="w-full bg-gray-900 border border-gray-800 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-blue-500" class="w-full bg-gray-900 border border-gray-800 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-blue-500"
@@ -234,7 +235,7 @@ content %}
<input <input
type="text" type="text"
id="field-web" id="field-web"
name="Web" name="web"
required required
maxlength="100" maxlength="100"
class="w-full bg-gray-900 border border-gray-800 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-blue-500" class="w-full bg-gray-900 border border-gray-800 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-blue-500"
@@ -249,7 +250,7 @@ content %}
<input <input
type="email" type="email"
id="field-mail" id="field-mail"
name="Mail" name="mail"
required required
maxlength="100" maxlength="100"
class="w-full bg-gray-900 border border-gray-800 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-blue-500" class="w-full bg-gray-900 border border-gray-800 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-blue-500"
+3 -2
View File
@@ -34,7 +34,7 @@ content %}
</p> </p>
</div> </div>
<a <a
href="{{ url_for('main.menu') }}" href="{{ url_for('menu.menu') }}"
class="px-4 py-2 bg-gray-800 hover:bg-gray-700 text-gray-200 text-sm font-medium rounded-lg transition duration-200" class="px-4 py-2 bg-gray-800 hover:bg-gray-700 text-gray-200 text-sm font-medium rounded-lg transition duration-200"
> >
Retour au menu Retour au menu
@@ -83,7 +83,8 @@ content %}
action="" action=""
class="space-y-4 flex-1 flex flex-col justify-between" class="space-y-4 flex-1 flex flex-col justify-between"
> >
<!-- ID technique caché --> <!-- Jeton CSRF + ID technique caché -->
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
<input type="hidden" id="field-id" name="id" /> <input type="hidden" id="field-id" name="id" />
<div class="space-y-4"> <div class="space-y-4">
+2 -1
View File
@@ -17,7 +17,8 @@ block content %}
</div> </div>
{% endif %} {% endwith %} {% endif %} {% endwith %}
<form method="POST" action="{{ url_for('main.index') }}" class="space-y-4"> <form method="POST" action="{{ url_for('auth.index') }}" class="space-y-4">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
<div> <div>
<label for="login" class="block text-sm font-medium text-gray-300 mb-1" <label for="login" class="block text-sm font-medium text-gray-300 mb-1"
>Login :</label >Login :</label
+8 -8
View File
@@ -15,7 +15,7 @@ header_title %}Tableau de bord{% endblock %} {% block content %}
<div class="grid grid-cols-1 md:grid-cols-2 gap-5 mb-8"> <div class="grid grid-cols-1 md:grid-cols-2 gap-5 mb-8">
<!-- btn 1 --> <!-- btn 1 -->
<a <a
href="{{ url_for('main.gestion_actions') }}" href="{{ url_for('actions.gestion_actions') }}"
class="py-6 px-6 bg-gray-950 hover:bg-blue-600/30 border-2 border-gray-800 hover:border-blue-500 rounded-xl text-white text-lg font-bold tracking-wide transition-all duration-200 flex items-center justify-center shadow-lg hover:scale-[1.01]" class="py-6 px-6 bg-gray-950 hover:bg-blue-600/30 border-2 border-gray-800 hover:border-blue-500 rounded-xl text-white text-lg font-bold tracking-wide transition-all duration-200 flex items-center justify-center shadow-lg hover:scale-[1.01]"
> >
1. Gérer les actions 1. Gérer les actions
@@ -23,7 +23,7 @@ header_title %}Tableau de bord{% endblock %} {% block content %}
<!-- btn 2 --> <!-- btn 2 -->
<a <a
href="{{ url_for('main.gestion_ordres') }}" href="{{ url_for('ordres.gestion_ordres') }}"
class="py-6 px-6 bg-gray-950 hover:bg-blue-600/30 border-2 border-gray-800 hover:border-blue-500 rounded-xl text-white text-lg font-bold tracking-wide transition-all duration-200 flex items-center justify-center shadow-lg hover:scale-[1.01]" class="py-6 px-6 bg-gray-950 hover:bg-blue-600/30 border-2 border-gray-800 hover:border-blue-500 rounded-xl text-white text-lg font-bold tracking-wide transition-all duration-200 flex items-center justify-center shadow-lg hover:scale-[1.01]"
> >
2. Ordres 2. Ordres
@@ -31,7 +31,7 @@ header_title %}Tableau de bord{% endblock %} {% block content %}
<!-- btn 3 --> <!-- btn 3 -->
<a <a
href="#" href="{{ url_for('comptes.gestion_comptes') }}"
class="py-6 px-6 bg-gray-950 hover:bg-blue-600/30 border-2 border-gray-800 hover:border-blue-500 rounded-xl text-white text-lg font-bold tracking-wide transition-all duration-200 flex items-center justify-center shadow-lg hover:scale-[1.01]" class="py-6 px-6 bg-gray-950 hover:bg-blue-600/30 border-2 border-gray-800 hover:border-blue-500 rounded-xl text-white text-lg font-bold tracking-wide transition-all duration-200 flex items-center justify-center shadow-lg hover:scale-[1.01]"
> >
3. Comptes 3. Comptes
@@ -39,7 +39,7 @@ header_title %}Tableau de bord{% endblock %} {% block content %}
<!-- btn 4 --> <!-- btn 4 -->
<a <a
href="{{ url_for('main.gestion_societes') }}" href="{{ url_for('societes.gestion_societes') }}"
class="py-6 px-6 bg-gray-950 hover:bg-blue-600/30 border-2 border-gray-800 hover:border-blue-500 rounded-xl text-white text-lg font-bold tracking-wide transition-all duration-200 flex items-center justify-center shadow-lg hover:scale-[1.01]" class="py-6 px-6 bg-gray-950 hover:bg-blue-600/30 border-2 border-gray-800 hover:border-blue-500 rounded-xl text-white text-lg font-bold tracking-wide transition-all duration-200 flex items-center justify-center shadow-lg hover:scale-[1.01]"
> >
4. Gestion sociétés 4. Gestion sociétés
@@ -47,7 +47,7 @@ header_title %}Tableau de bord{% endblock %} {% block content %}
<!-- btn 5 --> <!-- btn 5 -->
<a <a
href="{{ url_for('main.gestion_utilisateurs') }}" href="{{ url_for('utilisateurs.gestion_utilisateurs') }}"
class="py-6 px-6 bg-gray-950 hover:bg-blue-600/30 border-2 border-gray-800 hover:border-blue-500 rounded-xl text-white text-lg font-bold tracking-wide transition-all duration-200 flex items-center justify-center shadow-lg hover:scale-[1.01]" class="py-6 px-6 bg-gray-950 hover:bg-blue-600/30 border-2 border-gray-800 hover:border-blue-500 rounded-xl text-white text-lg font-bold tracking-wide transition-all duration-200 flex items-center justify-center shadow-lg hover:scale-[1.01]"
> >
5. Gestion utilisateurs 5. Gestion utilisateurs
@@ -55,7 +55,7 @@ header_title %}Tableau de bord{% endblock %} {% block content %}
<!-- btn 6 --> <!-- btn 6 -->
<a <a
href="#" href="{{ url_for('analyse.analyse') }}"
class="py-6 px-6 bg-gray-950 hover:bg-blue-600/30 border-2 border-gray-800 hover:border-blue-500 rounded-xl text-white text-lg font-bold tracking-wide transition-all duration-200 flex items-center justify-center shadow-lg hover:scale-[1.01]" class="py-6 px-6 bg-gray-950 hover:bg-blue-600/30 border-2 border-gray-800 hover:border-blue-500 rounded-xl text-white text-lg font-bold tracking-wide transition-all duration-200 flex items-center justify-center shadow-lg hover:scale-[1.01]"
> >
6. Stat historique 6. Stat historique
@@ -63,7 +63,7 @@ header_title %}Tableau de bord{% endblock %} {% block content %}
<!-- btn 7 --> <!-- btn 7 -->
<a <a
href="{{ url_for('main.gestion_import_export_actions_csv') }}" href="{{ url_for('historique.gestion_import_export_actions_csv') }}"
class="py-6 px-6 bg-gray-950 hover:bg-blue-600/30 border-2 border-gray-800 hover:border-blue-500 rounded-xl text-white text-lg font-bold tracking-wide transition-all duration-200 flex items-center justify-center shadow-lg hover:scale-[1.01]" class="py-6 px-6 bg-gray-950 hover:bg-blue-600/30 border-2 border-gray-800 hover:border-blue-500 rounded-xl text-white text-lg font-bold tracking-wide transition-all duration-200 flex items-center justify-center shadow-lg hover:scale-[1.01]"
> >
7. Import/Export Actions CSV 7. Import/Export Actions CSV
@@ -71,7 +71,7 @@ header_title %}Tableau de bord{% endblock %} {% block content %}
<!-- btn 8 --> <!-- btn 8 -->
<a <a
href="#" href="{{ url_for('historique.gestion_import_historique_csv') }}"
class="py-6 px-6 bg-gray-950 hover:bg-blue-600/30 border-2 border-gray-800 hover:border-blue-500 rounded-xl text-white text-lg font-bold tracking-wide transition-all duration-200 flex items-center justify-center shadow-lg hover:scale-[1.01]" class="py-6 px-6 bg-gray-950 hover:bg-blue-600/30 border-2 border-gray-800 hover:border-blue-500 rounded-xl text-white text-lg font-bold tracking-wide transition-all duration-200 flex items-center justify-center shadow-lg hover:scale-[1.01]"
> >
8. Import historique CSV 8. Import historique CSV
+381
View File
@@ -0,0 +1,381 @@
.
├── AGENTS.md
├── app
│   ├── __init__.py
│   ├── models.py
│   ├── __pycache__
│   │   ├── __init__.cpython-311.pyc
│   │   └── routes.cpython-311.pyc
│   ├── routes.py
│   ├── services
│   │   ├── analysis.py
│   │   ├── database.py
│   │   ├── indicators.py
│   │   ├── __init__.py
│   │   └── __pycache__
│   │   ├── analysis.cpython-311.pyc
│   │   ├── database.cpython-311.pyc
│   │   ├── indicators.cpython-311.pyc
│   │   └── __init__.cpython-311.pyc
│   ├── static
│   │   ├── css
│   │   ├── images
│   │   │   ├── favicon.png
│   │   │   └── GFBlancLogo.png
│   │   └── js
│   │   └── charts.js
│   └── templates
│   ├── analyse.html
│   ├── base.html
│   ├── gestion_actions.html
│   ├── gestion_comptes.html
│   ├── gestion_import_export_actions_csv.html
│   ├── gestion_import_historique_csv.html
│   ├── gestion_ordres.html
│   ├── gestion_societes.html
│   ├── gestion_utilisateurs.html
│   ├── index.html
│   └── menu.html
├── arborescence.txt
├── .devcontainer
│   └── devcontainer.json
├── docker-compose.yml
├── Dockerfile
├── .dockerignore
├── .env
├── .git
│   ├── COMMIT_EDITMSG
│   ├── config
│   ├── description
│   ├── HEAD
│   ├── hooks
│   │   ├── applypatch-msg.sample
│   │   ├── commit-msg.sample
│   │   ├── fsmonitor-watchman.sample
│   │   ├── post-update.sample
│   │   ├── pre-applypatch.sample
│   │   ├── pre-commit.sample
│   │   ├── pre-merge-commit.sample
│   │   ├── prepare-commit-msg.sample
│   │   ├── pre-push.sample
│   │   ├── pre-rebase.sample
│   │   ├── pre-receive.sample
│   │   ├── push-to-checkout.sample
│   │   ├── sendemail-validate.sample
│   │   └── update.sample
│   ├── index
│   ├── info
│   │   └── exclude
│   ├── logs
│   │   ├── HEAD
│   │   └── refs
│   │   ├── heads
│   │   │   └── main
│   │   └── remotes
│   │   └── origin
│   │   ├── HEAD
│   │   └── main
│   ├── objects
│   │   ├── 00
│   │   │   └── bd6ff6fdd29e050127343d217b47a267df712c
│   │   ├── 01
│   │   │   └── e0502fd1dd04a4689ab4093c65b049888d2848
│   │   ├── 02
│   │   │   └── 07cb7e5bc7d04931b6485cf180d1884e9689f9
│   │   ├── 04
│   │   │   ├── 08ddf18487e77856a3410c54a512343f0add37
│   │   │   └── e6dccc12264d2ac6554b7120b5f3b73833f2a9
│   │   ├── 07
│   │   │   └── 9031ef84904e911299a303bfb5096834df5d70
│   │   ├── 0a
│   │   │   └── dc0e5948aba4d913acd5c47ea3084ef42072c8
│   │   ├── 0c
│   │   │   └── 134ee9dce2f8ecafb266fa9fcca840416135dd
│   │   ├── 0d
│   │   │   └── a3b25bfcd0156e1868a116b69e2ba57b14d001
│   │   ├── 10
│   │   │   └── 9ca2310b66a98ec003c85f0f1113cbde00b24d
│   │   ├── 11
│   │   │   └── 2ab5b90d0d231b89e053775c3a4abe3f7fb0ad
│   │   ├── 12
│   │   │   └── f8d7e25f2d157a304a8167bd16c888b37fa2fa
│   │   ├── 15
│   │   │   └── f531461d36bd5067dcf9b1d7df8d284682c700
│   │   ├── 17
│   │   │   └── 902472fb99110700c56b93b6edd5eed36e865f
│   │   ├── 1b
│   │   │   └── 21a3e17e1ecc4f961b8575ce0cd8ae0ac8e79f
│   │   ├── 1e
│   │   │   └── b57085067042a37dbb9960d814ca9bf4b89747
│   │   ├── 1f
│   │   │   ├── 07e48b3a620bf69ec029e531e3ebbcf9718a3e
│   │   │   ├── 2089047f9abbd69b117158a36a2d8a529ca90b
│   │   │   └── 47a7c5e161f973eefdc3f8074fb31e4c7ec92b
│   │   ├── 20
│   │   │   └── 57d693cd0b411a9c468ddec6f3732ec7117e03
│   │   ├── 24
│   │   │   └── 3f4879653fdb1f4b1b5ce5d06a1a7ebe4aea57
│   │   ├── 26
│   │   │   ├── b6cd9e153bd5a18968289008c4868e643700d7
│   │   │   └── f28323844f6bfd9eec20261f44e3e57b6974b5
│   │   ├── 28
│   │   │   └── 7192f782efafb53ae63f2c879fb8c5e0cdf69e
│   │   ├── 2a
│   │   │   └── 1425272ff8d7a216715148ff59c7bb53ab3686
│   │   ├── 34
│   │   │   └── eba76fb863d7f19e15e72456dc97e8392d7fdd
│   │   ├── 36
│   │   │   └── 2ccfc03743c02d80b01ed9dfcd92caa35b14ca
│   │   ├── 37
│   │   │   └── e5ecfa8dbe2a7f89f1b2738ed01227cd8880e3
│   │   ├── 3b
│   │   │   ├── 0b0bad6147de77557e8edfbaca6194b2530c1f
│   │   │   ├── 16a2452244bf4c7f34e7931a5d92523c85ffd6
│   │   │   └── 517e20c82a2cbf1915dc8e31cf1b6fb86c7ebb
│   │   ├── 3d
│   │   │   └── e8bf9d59f8409f1dc3f72e95ee0156e5bedab8
│   │   ├── 3f
│   │   │   └── 1b0becfcf2ce89ce53dee705f727da43b57065
│   │   ├── 40
│   │   │   ├── cd5eed2a199166541eedd182945a7568546f03
│   │   │   └── ed00b9c9a18bf78ea2806b05e4eb9f638018fe
│   │   ├── 41
│   │   │   └── 39ac631013376273c279668af6fce888eb61fb
│   │   ├── 44
│   │   │   └── 5b141a30570cf3c4c430f38aca3998e40c5365
│   │   ├── 46
│   │   │   └── 885453b18d8ff255ed537b1f06abc5de83f803
│   │   ├── 49
│   │   │   ├── 3740d03f7d957864dd6cd0c187a4bea57c0bab
│   │   │   └── e440e17c5be6abc96049f11a244e2ea580ee52
│   │   ├── 4c
│   │   │   └── 78c73f7e5be001c434da09bd9ddfe5153fc25c
│   │   ├── 4d
│   │   │   ├── 3937c88f7b778219e034addb342d3b90b3ada2
│   │   │   ├── 71371f2136c3ef10bf2e1376ebaa3f2958bca6
│   │   │   └── e00820c85d27e1917915962b70d753364ea7fe
│   │   ├── 50
│   │   │   └── 5b9801c3db189ea858001626ae4741b3918bf6
│   │   ├── 52
│   │   │   ├── 1ae0f442e731579df467694823a050b3665f12
│   │   │   └── 3957cd018743a763ec75e678d75b1c70b8ba82
│   │   ├── 54
│   │   │   └── 814d5f0580b785d5b8fe123ca89af21f3e11ac
│   │   ├── 55
│   │   │   ├── 636030fef0de78019f78e8d5ef947f67e12eb2
│   │   │   └── f58e0a29126613c2d9517e1bebda9cc47b7802
│   │   ├── 56
│   │   │   └── 4f4846027c485e92cb280f84a470e40bd62447
│   │   ├── 57
│   │   │   └── 2e8acf0e6194567bd8d59b3ac342195f6d3966
│   │   ├── 59
│   │   │   └── af163824c44be62dfbb06df9e71769563a33e1
│   │   ├── 5a
│   │   │   └── 26a100fa4c7811822dd8dd3c895f60c364de15
│   │   ├── 5b
│   │   │   └── 6ac4bd24d2dfcc5dd2e94cd16b123e85bec09d
│   │   ├── 60
│   │   │   ├── 615fd6903be90071138712ae7175ae321d0645
│   │   │   └── 7fe553d40f715855d6fc92058e190eb08f2669
│   │   ├── 63
│   │   │   └── 3368c1d7cd2077e27d9d3ceb03a4032a2cc9c9
│   │   ├── 65
│   │   │   └── 92ffbcc5cb696c191ef2b69194de0b810b19f8
│   │   ├── 66
│   │   │   └── e2180d741309c85885cff73321ae9b198fff92
│   │   ├── 67
│   │   │   └── a0147412bd348f67a3b168a1c7496b0b7082f3
│   │   ├── 6b
│   │   │   └── 0cea8b3fe318c88578bff8627532672199f8ba
│   │   ├── 6d
│   │   │   └── 018a514d3d9a98f2d3f4d5822be8a83b6deb8c
│   │   ├── 6e
│   │   │   └── 5d15c70803ba6c93947cd46f891524f9534ae9
│   │   ├── 70
│   │   │   └── 6bdce8292187fd421e751d94ee444632e7b945
│   │   ├── 71
│   │   │   └── 1ff3311b54b32fff3606cf4c635359d60f4c60
│   │   ├── 72
│   │   │   ├── 352c8c35a4cde1575d9b205785ba6474b8b637
│   │   │   └── 47f112f4f746c8ae33031011a3b0af449fc5d4
│   │   ├── 74
│   │   │   └── 536771507e4561928a31c31e3cd1c9e5f89428
│   │   ├── 75
│   │   │   ├── 92ef5ca86e1351534de19233c580f0c259d4f0
│   │   │   └── c801d89931e2c327fed73a95da59e6b830e089
│   │   ├── 7e
│   │   │   ├── 39538816f4b9dacf025dff4100efa246a44c0b
│   │   │   └── 694b15fcebcd3ac03ddbc248154aac4d698206
│   │   ├── 82
│   │   │   └── bfeb65501ede38ef71f68d75161df39992917e
│   │   ├── 83
│   │   │   ├── 218e12f47b9ea37d97a9e71a138e0343a4ef41
│   │   │   └── 7368aef4264810159f3951edbed316977aaa44
│   │   ├── 85
│   │   │   ├── 38f78cce5d41ea9e620668a243e160dda226e0
│   │   │   ├── eb889e34fb76fde82c995c0e0d3496aeb21a09
│   │   │   └── f8daca661ff70b9692ac0d02cee98d3215c7ec
│   │   ├── 86
│   │   │   └── 57696025f6bc6d93d2141302f7f746a0ded69b
│   │   ├── 87
│   │   │   └── b390931d5dd16697c7e346a91309843f1da5a0
│   │   ├── 8b
│   │   │   └── 084e5b50e0ce6a5a1a6349540fe8ef9daffcae
│   │   ├── 8c
│   │   │   └── 69706e4f8b5599372f65127837a1645debdabb
│   │   ├── 8d
│   │   │   └── 0bddd1b2f664f31617688340dbe10e67007e68
│   │   ├── 90
│   │   │   └── ebd9162d702e2ab49c4d5db4ab282997e65640
│   │   ├── 94
│   │   │   └── 8c55763940965d16649effe28b26f4ba1cbbbb
│   │   ├── 97
│   │   │   ├── 6a00e0fa6d49a08d7711fed68e6e01847c2865
│   │   │   └── 7599c17a31a9a5e12a5a34bcbcc2ced5c2d641
│   │   ├── 99
│   │   │   └── 426023929b3e0ec04abf4a8b3f29ced2799a87
│   │   ├── 9c
│   │   │   └── 32e129e3dd47b2e5e09c61ea5f1b4bef7c2c33
│   │   ├── a1
│   │   │   └── 0d068b5d17148c5a6f9452e9ad0b39794fc9b2
│   │   ├── a8
│   │   │   ├── 37fe05c7e3115b53f8955f83d0c4a39d11ef10
│   │   │   └── c06d8055c22bc855d46eee3f611dc95902e454
│   │   ├── a9
│   │   │   └── 0bec8d42e49980ab4f00d64decd060ebe70057
│   │   ├── b0
│   │   │   └── 6517db23c58555c268884c502e5020a94b4fa4
│   │   ├── b1
│   │   │   ├── 20c2bd728d41675a7be4d4497b1340c7472910
│   │   │   └── 6b6eef9fc8833a6dc404fa3f6ec2228b396e37
│   │   ├── b2
│   │   │   └── c241797b5f9473c35ffeff236d8c2097c41002
│   │   ├── b6
│   │   │   └── 7ecfc5d115379a92ffa17747bb7c294b42a6cb
│   │   ├── b8
│   │   │   └── e0eba8c9f85c6ecf2fe3d353824f542fb57c15
│   │   ├── b9
│   │   │   └── 74611196cf8def7d9dcaa4ac270f2a0c863cc1
│   │   ├── ba
│   │   │   └── 4279c3c3b5125d11c5c025798fcd2a33d2fab4
│   │   ├── bb
│   │   │   └── e0360e7535ce1604e3f66451e0ab6f32dbac12
│   │   ├── be
│   │   │   ├── d31a9eeaf84dee7039b8809110286c469eee69
│   │   │   └── ebbdc835054832a5b92348b8752f2622f88636
│   │   ├── c1
│   │   │   └── 54b83412cb4198a6f2e29deb09606ccc2d2a23
│   │   ├── c2
│   │   │   └── 2368611269a9fb12cfc034289cf671469704a1
│   │   ├── c3
│   │   │   └── 2a4fffdd5d82253a687a8c7c39d71f3cd889f7
│   │   ├── c4
│   │   │   └── 1f306b9abb67f5ccb24b3b35ab18b1524399ad
│   │   ├── c8
│   │   │   └── d18a6cef831ca56e18f7d4ff5d7c7ec58b3010
│   │   ├── cb
│   │   │   └── 33281ba30cd46880876234e8d2ea7065ecf3de
│   │   ├── cd
│   │   │   └── 902d78081683c69945b34ce67a14add6426b28
│   │   ├── ce
│   │   │   └── dab25514e00d824c2f9d345f64e756e473173a
│   │   ├── cf
│   │   │   └── ad237cf6df989cb1d54aad4bc5eaa55b8b5c98
│   │   ├── d0
│   │   │   └── c784fa00768246e4c95c168a38206f055c166f
│   │   ├── d6
│   │   │   └── b8bc493ff42da63649b8be6ec989a12319a567
│   │   ├── d7
│   │   │   └── d031b8e073f1e07f73ad468b8031e1950c6eff
│   │   ├── d8
│   │   │   └── 65d3501ada082e077cdaa32439dc22a4966561
│   │   ├── d9
│   │   │   └── d851c33add105e23ba383feb3cd131bfc6a8fc
│   │   ├── dc
│   │   │   ├── 14d536b88178d3d74870613cfc938fd90c793a
│   │   │   └── 6abeb9e2c702591fe216dd10b8b04941e117f7
│   │   ├── dd
│   │   │   ├── 736b72374acab09a8e84bd1bda04327c63c804
│   │   │   └── f0420841e632270a504ac90e7e8ae4837d56e8
│   │   ├── df
│   │   │   ├── 76b34a23247f8020fc8b7aefbde2074c812837
│   │   │   └── 8b33f6a40ca1c710ebc114d16b9a548db16a37
│   │   ├── e0
│   │   │   └── e7d51df0cdcc54399d200e1c1604d0fa813378
│   │   ├── e3
│   │   │   ├── 1e4572cabd86823c7be31f034c223b1e57b3d7
│   │   │   └── 8c247ebbc0189b3af17e3294e1429f22e5f61b
│   │   ├── e6
│   │   │   └── 9de29bb2d1d6434b8b29ae775ad8c2e48c5391
│   │   ├── ef
│   │   │   └── 58a8a234dd56e699430061b86b005f73939464
│   │   ├── f2
│   │   │   └── fd513263d428a680a98a494a7492f8da0de43a
│   │   ├── f3
│   │   │   └── 7f5b1e4509262eb65122f91220f04f080182ed
│   │   ├── f7
│   │   │   └── 2cd45703375c00e975a09cee50da3619d3277e
│   │   ├── f8
│   │   │   └── 91e8a154f2d4357cbb7e4af8c1805dfa310baf
│   │   ├── fa
│   │   │   ├── e895d8ca270a7538d299e63ab2dd4663523f95
│   │   │   └── f1bce15949cbe30f2cf840aa5e9e9e975829ab
│   │   ├── fb
│   │   │   └── 4fd6e9a038e3fd4660396a0afd78f0f272aa48
│   │   ├── fc
│   │   │   └── 1ca97cb1f9f2d092206a57833214b2fa28972a
│   │   ├── ff
│   │   │   └── 14f22d999bb7f217cc67c7a2cb02e5a3f1cf4b
│   │   ├── info
│   │   └── pack
│   ├── opencode
│   └── refs
│   ├── heads
│   │   └── main
│   ├── remotes
│   │   └── origin
│   │   ├── HEAD
│   │   └── main
│   └── tags
├── .gitignore
├── migrations
│   ├── apply_migrations.py
│   └── schema.sql
├── mysql_data
│   ├── auto.cnf
│   ├── binlog.000001
│   ├── binlog.000002
│   ├── binlog.000003
│   ├── binlog.000004
│   ├── binlog.000005
│   ├── binlog.index
│   ├── bourse_db [error opening dir]
│   ├── ca-key.pem
│   ├── ca.pem
│   ├── client-cert.pem
│   ├── client-key.pem
│   ├── #ib_16384_0.dblwr
│   ├── #ib_16384_1.dblwr
│   ├── ib_buffer_pool
│   ├── ibdata1
│   ├── #innodb_redo [error opening dir]
│   ├── #innodb_temp [error opening dir]
│   ├── mysql [error opening dir]
│   ├── mysql.ibd
│   ├── mysql.sock -> /var/run/mysqld/mysqld.sock
│   ├── performance_schema [error opening dir]
│   ├── private_key.pem
│   ├── public_key.pem
│   ├── server-cert.pem
│   ├── server-key.pem
│   ├── sys [error opening dir]
│   ├── undo_001
│   └── undo_002
├── requirements.txt
├── run.py
└── .vscode
└── settings.json
147 directories, 230 files
tree -a > arborescence.txt
-112
View File
@@ -1,112 +0,0 @@
import time
import requests
import mysql.connector
def importer_actions():
API_KEY = "6a6235c8a742b2.89238466"
MYSQL = {
"host": "db",
"port": 3306,
"user": "root",
"password": "sysadm-1963",
"database": "bourse"
}
EXCHANGES = [
"PA", "US", "BR", "KLSE", "LS", "XETRA", "LU", "OL", "ST", "CO", "IR", "KO", "AU", "SHE", "SHG", "AT",
"F", "STU", "MU", "HA", "DU", "HM", "VI", "SW", "TO", "AU", "HK", "TWO", "MC"
]
print("Connexion à la base de données MySQL...")
try:
db_conn = mysql.connector.connect(**MYSQL)
cursor = db_conn.cursor()
print("Connecté avec succès à la base MySQL : [bourse]")
except Exception as e:
print(f"Erreur de connexion MySQL : {e}")
return
print("Vidage de la table les_actions...")
try:
cursor.execute("TRUNCATE TABLE les_actions;")
db_conn.commit()
except Exception as e:
print(f"Erreur lors du TRUNCATE : {e}")
sql = """
INSERT INTO les_actions (isin, ticker, name, exchange, pays, devise)
VALUES (%s, %s, %s, %s, %s, %s)
"""
total_global = 0
try:
for exchange in EXCHANGES:
url = f"https://eodhd.com/api/exchange-symbol-list/{exchange}?api_token={API_KEY}&fmt=json"
print(f"\n--- Traitement de la bourse : {exchange} ---")
req_timeout = 300 if exchange == "US" else 60
try:
r = requests.get(url, timeout=req_timeout)
if r.status_code != 200:
print(f"[{exchange}] Erreur HTTP {r.status_code}")
continue
data = r.json()
except Exception as err:
print(f"[{exchange}] Erreur requête ou timeout : {err}")
continue
if not isinstance(data, list):
print(f"[{exchange}] Le format reçu n'est pas une liste.")
continue
print(f"[{exchange}] {len(data)} éléments reçus. Insertion en cours...")
lignes = []
for item in data:
isin = item.get("ISIN") or item.get("isin")
ticker = item.get("Code") or item.get("code")
name = item.get("Name") or item.get("name")
exch = item.get("Exchange") or item.get("exchange") or exchange
pays = item.get("Country") or item.get("country")
devise = item.get("Currency") or item.get("currency")
if not ticker:
continue
lignes.append((
str(isin)[:12] if isin else None,
str(ticker)[:20] if ticker else None,
str(name)[:150] if name else None,
str(exch)[:50] if exch else None,
str(pays)[:50] if pays else None,
str(devise)[:10] if devise else None
))
if lignes:
# Utilisation d'executemany par paquets pour un maximum de performance
batch_size = 5000
for i in range(0, len(lignes), batch_size):
batch = lignes[i:i + batch_size]
cursor.executemany(sql, batch)
db_conn.commit()
total_global += len(lignes)
print(f"-> Succès : {len(lignes)} actions insérées pour {exchange}.")
time.sleep(1)
# Vérification finale du nombre de lignes dans la table
cursor.execute("SELECT COUNT(*) FROM les_actions;")
count = cursor.fetchone()[0]
print(f"\nTerminé ! Total global traité : {total_global} lignes. Comptage réel en base : {count} lignes.")
except Exception as e:
print(f"Erreur générale : {e}")
finally:
cursor.close()
db_conn.close()
print("Connexion fermée.")
if __name__ == "__main__":
importer_actions()
-115
View File
@@ -1,115 +0,0 @@
#!/usr/bin/env python3
import csv
import mysql.connector
# ==========================================
# CONFIGURATION
# ==========================================
CSV_FILE = "/app/isin.csv"
# Pays associé au fichier CSV
PAYS = "France"
MYSQL = {
"host": "db",
"port": 3306,
"user": "root",
"password": "sysadm-1963",
"database": "bourse"
}
# ==========================================
# CONNEXION MYSQL
# ==========================================
try:
db = mysql.connector.connect(**MYSQL)
print("Connexion MySQL OK")
except Exception as e:
print("Erreur MySQL :", e)
exit(1)
cursor = db.cursor(dictionary=True)
# ==========================================
# CHARGEMENT DU CSV
# ==========================================
print("Chargement du CSV...")
csv_data = {}
with open(CSV_FILE, newline="", encoding="utf-8-sig") as f:
reader = csv.reader(f, delimiter=";")
for row in reader:
if len(row) < 3:
continue
isin = row[0].strip().upper()
ticker = row[2].strip().upper()
if ticker:
csv_data[(ticker, PAYS.upper())] = isin
print(f"{len(csv_data)} ISIN chargés.")
# ==========================================
# LECTURE DE LA TABLE
# ==========================================
cursor.execute("""
SELECT
id,
ticker,
pays,
isin
FROM les_actions
""")
lignes = cursor.fetchall()
print(f"{len(lignes)} actions lues.")
# ==========================================
# MISE A JOUR
# ==========================================
sql_update = """
UPDATE les_actions
SET isin = %s
WHERE id = %s
"""
update_data = []
for ligne in lignes:
ticker = (ligne["ticker"] or "").strip().upper()
pays = (ligne["pays"] or "").strip().upper()
cle = (ticker, pays)
if cle in csv_data:
update_data.append((
csv_data[cle],
ligne["id"]
))
print(f"{len(update_data)} lignes à mettre à jour.")
if update_data:
cursor.executemany(sql_update, update_data)
db.commit()
print("===================================")
print(f"{len(update_data)} ISIN mis à jour.")
print("===================================")
cursor.close()
db.close()
-31
View File
@@ -1,31 +0,0 @@
from werkzeug.security import generate_password_hash
from sqlalchemy import text
from app import app, db
with app.app_context():
# Définition des informations de l'utilisateur
login_name = "jfgiraud"
nom = "GIRAUD"
prenom = "Jean-François"
mail = "jfgiraud@giraud-finance.com"
# Génération du hash sécurisé pour le mot de passe "lyon4"
hashed_password = generate_password_hash("lyon4")
# Requête d'insertion
query = text("""
INSERT INTO login (login, nom, prenom, mail, pass, actif)
VALUES (:login, :nom, :prenom, :mail, :pass, 1)
ON DUPLICATE KEY UPDATE
pass = :pass, nom = :nom, prenom = :prenom, mail = :mail, actif = 1
""")
db.session.execute(query, {
"login": login_name,
"nom": nom,
"prenom": prenom,
"mail": mail,
"pass": hashed_password
})
db.session.commit()
print(f"Utilisateur '{login_name}' créé/mis à jour avec succès avec un mot de passe haché !")
-5
View File
@@ -1,5 +0,0 @@
Pour exécuter ce script directement à l'intérieur du conteneur web utiliser la commande suivante dans votre terminal :
docker compose exec web python create_user.py
Une fois cette commande passée, vous pourrez vous connecter sur votre page d'accueil avec le login jfgiraud et le mot de passe lyon4.
+15 -13
View File
@@ -1,13 +1,10 @@
services: services:
db: db:
image: mysql:8.0 image: mysql:8.0
environment: env_file: .env
MYSQL_ROOT_PASSWORD: sysadm-1963
MYSQL_DATABASE: bolsa
MYSQL_USER: jfgiraud
MYSQL_PASSWORD: lyon4
volumes: volumes:
- db_data:/var/lib/mysql - db_data:/var/lib/mysql
- ./migrations/schema.sql:/docker-entrypoint-initdb.d/01_schema.sql:ro
healthcheck: healthcheck:
test: test:
[ [
@@ -18,34 +15,39 @@ services:
"127.0.0.1", "127.0.0.1",
"-u", "-u",
"root", "root",
"-psysadm-1963", "-p${MYSQL_ROOT_PASSWORD}",
] ]
interval: 3s interval: 3s
timeout: 3s timeout: 3s
retries: 10 retries: 10
restart: unless-stopped
web: web:
build: . build: .
command: python run.py env_file: .env
environment:
- FLASK_ENV=${FLASK_ENV}
volumes: volumes:
- .:/app # Montage restreint au code applicatif (pas .git / mysql_data / .env)
- ./app:/app/app
ports: ports:
- "5000:5000" - "5000:5000"
environment:
- DATABASE_URL=mysql+pymysql://jfgiraud:lyon4@db/bolsa
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
restart: unless-stopped
phpmyadmin: phpmyadmin:
image: phpmyadmin/phpmyadmin image: phpmyadmin/phpmyadmin
ports: env_file: .env
- "8080:80"
environment: environment:
PMA_HOST: db PMA_HOST: db
MYSQL_ROOT_PASSWORD: sysadm-1963 ports:
# Accès local uniquement (pas d'exposition publique)
- "8080:80"
depends_on: depends_on:
- db - db
restart: unless-stopped
volumes: volumes:
db_data: db_data:
+89
View File
@@ -0,0 +1,89 @@
"""
Application séquentielle des migrations SQL du dossier `migrations/`
sur la base « bolsa ». À exécuter depuis la racine du projet :
python migrations/apply_migrations.py
Lit la configuration MySQL depuis les variables d'environnement (.env).
N'applique que les fichiers non déjà enregistrés dans la table
`__migrations__` (créée automatiquement).
"""
import os
import sys
from pathlib import Path
import pymysql
from dotenv import load_dotenv
load_dotenv()
MIGRATIONS_DIR = Path(__file__).parent
def get_connection():
return pymysql.connect(
host=os.environ.get("MYSQL_HOST", "db"),
port=int(os.environ.get("MYSQL_PORT", "3306")),
user=os.environ.get("MYSQL_USER", "jfgiraud"),
password=os.environ.get("MYSQL_PASSWORD", ""),
database=os.environ.get("MYSQL_DATABASE", "bolsa"),
charset="utf8mb4",
autocommit=False,
)
def main():
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
"CREATE TABLE IF NOT EXISTS `__migrations__` ("
" `id` int NOT NULL AUTO_INCREMENT,"
" `filename` varchar(255) NOT NULL,"
" `applied_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,"
" PRIMARY KEY (`id`),"
" UNIQUE KEY `idx_filename` (`filename`)"
") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
)
conn.commit()
cur.execute("SELECT filename FROM `__migrations__`")
applied = {row[0] for row in cur.fetchall()}
sql_files = sorted(
f for f in MIGRATIONS_DIR.glob("*.sql")
if f.name != "schema.sql"
)
if not sql_files:
print("Aucune migration à appliquer.")
return
for sql_file in sql_files:
if sql_file.name in applied:
print(f"[déjà appliquée] {sql_file.name}")
continue
print(f"[application] {sql_file.name}")
sql = sql_file.read_text(encoding="utf-8")
# Découpage sur les ';' pour exécuter instruction par instruction
statements = [s.strip() for s in sql.split(";") if s.strip()]
for stmt in statements:
cur.execute(stmt)
cur.execute(
"INSERT INTO `__migrations__` (filename) VALUES (%s)",
(sql_file.name,),
)
conn.commit()
print(f"[OK] {sql_file.name}")
except Exception as e:
conn.rollback()
print(f"[ERREUR] {e}", file=sys.stderr)
sys.exit(1)
finally:
conn.close()
if __name__ == "__main__":
main()
+128
View File
@@ -0,0 +1,128 @@
-- =====================================================================
# Schéma de la base « bolsa » version de référence (snake_case)
-- =====================================================================
# Tables : login, actions, societe, compte, ordre, ordre_pied
# Encodage : utf8mb4 / utf8mb4_0900_ai_ci
# Ce fichier est exécuté automatiquement par Docker sur une base vierge
# (monté dans /docker-entrypoint-initdb.d/). Sur une base existante, les
# CREATE TABLE IF NOT EXISTS sont sans effet.
-- =====================================================================
CREATE TABLE IF NOT EXISTS `login` (
`id` int NOT NULL AUTO_INCREMENT,
`login` varchar(50) NOT NULL,
`nom` varchar(100) NOT NULL,
`prenom` varchar(100) NOT NULL,
`mail` varchar(255) NOT NULL,
`pass` varchar(255) NOT NULL,
`actif` tinyint(1) DEFAULT NULL,
`creer_le` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
`modifie_le` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
`reset_token` varchar(255) DEFAULT NULL,
`reset_token_created_at` timestamp NULL DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `idx_login` (`login`),
UNIQUE KEY `idx_mail` (`mail`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS `actions` (
`id` int NOT NULL AUTO_INCREMENT,
`isin` varchar(50) NOT NULL,
`ticker` varchar(50) DEFAULT NULL,
`company_name` varchar(255) DEFAULT NULL,
`exchange` varchar(50) NOT NULL,
`pays` varchar(50) NOT NULL,
`currency` varchar(10) NOT NULL,
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `isin` (`isin`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS `societe` (
`id` int NOT NULL AUTO_INCREMENT,
`nom` varchar(60) NOT NULL,
`forme` varchar(10) NOT NULL,
`capital` int NOT NULL,
`siren` varchar(20) NOT NULL,
`siret` varchar(20) NOT NULL,
`rcs` varchar(50) NOT NULL,
`tva` varchar(50) NOT NULL,
`naf` varchar(10) NOT NULL,
`tel` varchar(30) NOT NULL,
`web` varchar(100) NOT NULL,
`mail` varchar(100) NOT NULL,
`created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS `compte` (
`id` int NOT NULL AUTO_INCREMENT,
`id_societe` int NOT NULL,
`date_operation` date NOT NULL,
`debit` decimal(15,2) DEFAULT '0.00',
`credit` decimal(15,2) DEFAULT '0.00',
`libelle` text,
`source` enum('manuel','ordre') NOT NULL DEFAULT 'manuel',
`created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_compte_societe` (`id_societe`),
CONSTRAINT `fk_compte_societe` FOREIGN KEY (`id_societe`)
REFERENCES `societe` (`id`) ON DELETE RESTRICT ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS `ordre` (
`id` int NOT NULL AUTO_INCREMENT,
`id_societe` int NOT NULL,
`isin` varchar(50) NOT NULL,
`plus_value` decimal(19,4) NOT NULL DEFAULT '0.0000',
`date_plus_value` date DEFAULT NULL,
`created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `fk_ordre_societe` (`id_societe`),
KEY `fk_ordre_actions` (`isin`),
CONSTRAINT `fk_ordre_societe` FOREIGN KEY (`id_societe`)
REFERENCES `societe` (`id`) ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT `fk_ordre_actions` FOREIGN KEY (`isin`)
REFERENCES `actions` (`isin`) ON DELETE RESTRICT ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS `ordre_pied` (
`id` int NOT NULL AUTO_INCREMENT,
`id_entete` int NOT NULL,
`date_op` date NOT NULL,
`quantite` int NOT NULL,
`prix_brut` decimal(19,4) DEFAULT NULL,
`frais_broker` decimal(19,4) DEFAULT NULL,
`frais_etat` decimal(19,4) DEFAULT NULL,
`frais_autre` decimal(19,4) DEFAULT NULL,
`sens` enum('achat','vente') NOT NULL,
`position` enum('long','short') NOT NULL,
`etat` enum('actif','soldé','annulé') NOT NULL,
`created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_ordre_pied_entete` (`id_entete`),
CONSTRAINT `fk_ordre_pied_entete` FOREIGN KEY (`id_entete`)
REFERENCES `ordre` (`id`) ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS `historique` (
`id` int NOT NULL AUTO_INCREMENT,
`isin` varchar(50) NOT NULL,
`date` date NOT NULL,
`price` decimal(19,4) DEFAULT NULL,
`open` decimal(19,4) DEFAULT NULL,
`hight` decimal(19,4) DEFAULT NULL,
`low` decimal(19,4) DEFAULT NULL,
`vol` int DEFAULT NULL,
`change` decimal(19,4) DEFAULT NULL,
`updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
`created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `idx_isin_date` (`isin`, `date`),
CONSTRAINT `fk_historique_actions` FOREIGN KEY (`isin`)
REFERENCES `actions` (`isin`) ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
+8
View File
@@ -1,17 +1,25 @@
blinker==1.9.0 blinker==1.9.0
certifi==2026.7.22 certifi==2026.7.22
charset-normalizer==3.4.9 charset-normalizer==3.4.9
cryptography
click==8.4.2 click==8.4.2
Flask==3.1.3 Flask==3.1.3
Flask-SQLAlchemy==3.1.1 Flask-SQLAlchemy==3.1.1
Flask-WTF==1.2.1
greenlet==3.5.4 greenlet==3.5.4
idna==3.18 idna==3.18
itsdangerous==2.2.0 itsdangerous==2.2.0
Jinja2==3.1.6 Jinja2==3.1.6
MarkupSafe==3.0.3 MarkupSafe==3.0.3
mysql-connector-python==9.7.0 mysql-connector-python==9.7.0
python-dotenv==1.1.1
numpy
pandas
PyMySQL==1.1.1
requests==2.34.2 requests==2.34.2
SQLAlchemy==2.0.51 SQLAlchemy==2.0.51
typing_extensions==4.16.0 typing_extensions==4.16.0
urllib3==2.7.0 urllib3==2.7.0
Werkzeug==3.1.8 Werkzeug==3.1.8
WTForms==3.1.2
gunicorn
+4 -2
View File
@@ -1,4 +1,6 @@
import os
from app import app from app import app
if __name__ == '__main__': if __name__ == "__main__":
app.run(host='0.0.0.0', port=5000, debug=True) debug = os.environ.get("FLASK_ENV") == "development"
app.run(host="0.0.0.0", port=5000, debug=debug)