Compare commits
13 Commits
b2c241797b
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| d9edd3ef1d | |||
| 41c2d3d280 | |||
| b7ca2acd85 | |||
| cde1c2bcdf | |||
| 4612853672 | |||
| b966f4a66c | |||
| 06ada48bd4 | |||
| 23852edeec | |||
| c2f97276b6 | |||
| a25043dc3f | |||
| 6e5d15c708 | |||
| 00bd6ff6fd | |||
| f37f5b1e45 |
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -16,5 +16,9 @@ __pycache__/
|
||||
# Fichiers de base de données locaux (si applicable)
|
||||
*.sqlite3
|
||||
|
||||
# Fichiers de configuration locaux
|
||||
.env
|
||||
.env.*
|
||||
|
||||
# Base de données MySQL Docker
|
||||
mysql_data/
|
||||
@@ -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
@@ -2,8 +2,19 @@ FROM python:3.11-slim
|
||||
|
||||
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
|
||||
|
||||
CMD ["python", "run.py"]
|
||||
#CMD ["python", "run.py"]
|
||||
CMD ["gunicorn","--bind","0.0.0.0:5000","--workers","3","run:app"]
|
||||
|
||||
+56
-10
@@ -1,19 +1,44 @@
|
||||
import os
|
||||
import time
|
||||
from flask import Flask, render_template
|
||||
from flask import Flask
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
from flask_wtf import CSRFProtect
|
||||
from sqlalchemy.exc import OperationalError
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
db = SQLAlchemy()
|
||||
csrf = CSRFProtect()
|
||||
|
||||
|
||||
def create_app():
|
||||
app = Flask(__name__)
|
||||
app.config['SQLALCHEMY_DATABASE_URI'] = 'mysql+pymysql://jfgiraud:lyon4@db/bolsa'
|
||||
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
|
||||
app.config['SECRET_KEY'] = 'votre_cle_secrete_tres_securisee_jfgiraud04051963++fin'
|
||||
|
||||
# --- Configuration depuis l'environnement ---
|
||||
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)
|
||||
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():
|
||||
max_retries = 15
|
||||
delay = 2
|
||||
@@ -22,17 +47,38 @@ def create_app():
|
||||
db.create_all()
|
||||
print("Base de données connectée et tables initialisées avec succès.")
|
||||
break
|
||||
except OperationalError as e:
|
||||
print(f"Tentative {i+1}/{max_retries} : MySQL n'est pas encore prêt. Nouvelle tentative dans {delay}s...")
|
||||
except OperationalError:
|
||||
print(f"Tentative {i+1}/{max_retries} : MySQL n'est pas encore prêt. "
|
||||
f"Nouvelle tentative dans {delay}s...")
|
||||
time.sleep(delay)
|
||||
else:
|
||||
raise Exception("Impossible d'initialiser la base de données après plusieurs tentatives.")
|
||||
|
||||
# Enregistrement des routes (Blueprint)
|
||||
from app.routes import main
|
||||
app.register_blueprint(main)
|
||||
# Enregistrement des routes (Blueprints)
|
||||
from app.routes import (
|
||||
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
|
||||
|
||||
|
||||
# Expose 'app' pour que run.py puisse l'importer directement
|
||||
app = create_app()
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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"))
|
||||
@@ -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))
|
||||
@@ -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"))
|
||||
@@ -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")
|
||||
@@ -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))
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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",
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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
@@ -1,5 +1,5 @@
|
||||
<!doctype html>
|
||||
<html lang="fr" class="dark">
|
||||
<html lang="fr" class="dark h-full">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
@@ -14,18 +14,23 @@
|
||||
|
||||
<!-- Tailwind CSS CDN -->
|
||||
<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>
|
||||
<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
|
||||
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">
|
||||
<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
|
||||
src="{{ url_for('static', filename='images/GFBlancLogo.png') }}"
|
||||
@@ -35,7 +40,7 @@
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Centre : Titre (agrandi avec text-4xl et police plus grasse) -->
|
||||
<!-- Centre : Titre -->
|
||||
<div class="absolute left-1/2 transform -translate-x-1/2">
|
||||
<h1 class="text-4xl font-extrabold tracking-wider text-white">Bolsa</h1>
|
||||
</div>
|
||||
@@ -61,7 +66,7 @@
|
||||
{% if session.get('user_id') %}
|
||||
<div class="pt-1.5 flex justify-end">
|
||||
<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"
|
||||
>
|
||||
Se déconnecter
|
||||
@@ -72,13 +77,13 @@
|
||||
</header>
|
||||
|
||||
<!-- 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 %}
|
||||
</main>
|
||||
|
||||
<!-- Footer Global -->
|
||||
<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 -->
|
||||
<div class="w-1/3"></div>
|
||||
@@ -101,7 +106,7 @@
|
||||
</div>
|
||||
</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') %}
|
||||
<script>
|
||||
fetch("https://api.ipify.org?format=json")
|
||||
@@ -109,9 +114,8 @@
|
||||
.then((data) => {
|
||||
document.getElementById("external-ip").textContent = data.ip;
|
||||
})
|
||||
.catch((error) => {
|
||||
document.getElementById("external-ip").textContent =
|
||||
"IP non disponible";
|
||||
.catch(() => {
|
||||
document.getElementById("external-ip").textContent = "IP non disponible";
|
||||
});
|
||||
</script>
|
||||
{% endif %}
|
||||
|
||||
@@ -34,7 +34,7 @@ content %}
|
||||
</p>
|
||||
</div>
|
||||
<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"
|
||||
>
|
||||
Retour au menu
|
||||
@@ -107,6 +107,7 @@ content %}
|
||||
action=""
|
||||
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" />
|
||||
|
||||
<div class="space-y-4">
|
||||
|
||||
@@ -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>
|
||||
</div>
|
||||
<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"
|
||||
>
|
||||
Retour au menu
|
||||
@@ -124,7 +124,7 @@ endblock %} {% block header_title %}Gestion CSV{% endblock %} {% block content
|
||||
>
|
||||
<!-- Formulaire ou lien pour l'Export (réduit) -->
|
||||
<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"
|
||||
>
|
||||
<svg
|
||||
@@ -145,11 +145,12 @@ endblock %} {% block header_title %}Gestion CSV{% endblock %} {% block content
|
||||
|
||||
<!-- Formulaire pour l'Import (prend toute la place restante) -->
|
||||
<form
|
||||
action="{{ url_for('main.import_actions_csv') }}"
|
||||
action="{{ url_for('historique.import_actions_csv') }}"
|
||||
method="POST"
|
||||
enctype="multipart/form-data"
|
||||
class="w-full sm:w-3/4 flex items-center gap-2"
|
||||
>
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
|
||||
<input
|
||||
type="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 %}
|
||||
@@ -1,7 +1,7 @@
|
||||
{% extends "base.html" %} {% block title %}Gestion des Ordres - Bolsa{% endblock
|
||||
%} {% block header_title %}Gestion des ordres{% endblock %} {% block content %}
|
||||
<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 -->
|
||||
<div
|
||||
@@ -18,12 +18,13 @@
|
||||
{% endfor %} {% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Box principale -->
|
||||
<!-- Box principale : Hauteur 100% du conteneur parent (qui occupe tout l'espace libre) -->
|
||||
<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
|
||||
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>
|
||||
<h2 class="text-2xl font-extrabold text-white flex items-center gap-2">
|
||||
@@ -40,9 +41,10 @@
|
||||
<!-- Formulaire combiné / Filtre Année + Société -->
|
||||
<form
|
||||
method="POST"
|
||||
action="{{ url_for('main.gestion_ordres') }}"
|
||||
action="{{ url_for('ordres.gestion_ordres') }}"
|
||||
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 -->
|
||||
<input
|
||||
type="hidden"
|
||||
@@ -71,16 +73,17 @@
|
||||
<span
|
||||
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>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<form
|
||||
method="POST"
|
||||
action="{{ url_for('main.gestion_ordres') }}"
|
||||
action="{{ url_for('ordres.gestion_ordres') }}"
|
||||
class="m-0"
|
||||
>
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
|
||||
<!-- Conserver l'année active lors du changement de société -->
|
||||
<input
|
||||
type="hidden"
|
||||
@@ -93,9 +96,9 @@
|
||||
onchange="this.form.submit()"
|
||||
>
|
||||
{% 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 %}
|
||||
<option value="{{ soc.id }}">{{ soc.Nom }}</option>
|
||||
<option value="{{ soc.id }}">{{ soc.nom }}</option>
|
||||
{% endif %} {% endfor %}
|
||||
</select>
|
||||
</form>
|
||||
@@ -109,7 +112,7 @@
|
||||
</button>
|
||||
|
||||
<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"
|
||||
>
|
||||
Retour au menu
|
||||
@@ -117,6 +120,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Corps scrollable de la box (flex-1 et overflow-y-auto pour isoler le défilement) -->
|
||||
<div class="p-4 sm:p-6 overflow-y-auto flex-1">
|
||||
<!-- Tableau -->
|
||||
<div
|
||||
class="bg-gray-950 border border-gray-800 rounded-xl p-2 overflow-x-auto"
|
||||
@@ -126,8 +131,8 @@
|
||||
{% if groupes_ordres %} {% for groupe in groupes_ordres %}
|
||||
<tr class="bg-blue-950 text-white font-semibold">
|
||||
<td colspan="12" class="py-2.5 px-3 text-base">
|
||||
{{ groupe.isin }} --- {{ groupe.company_name }} ( {{ groupe.ticker
|
||||
}} )
|
||||
{{ groupe.isin }} --- {{ groupe.company_name }} ( {{
|
||||
groupe.ticker }} )
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -161,9 +166,13 @@
|
||||
<td class="py-2 px-3">
|
||||
{{ "{:,.0f}".format(p.quantite).replace(",", " ") }}
|
||||
</td>
|
||||
<td class="py-2 px-3">{{ "{:,.3f}".format(p.prix_brut) }}</td>
|
||||
<td class="py-2 px-3">{{ "{:,.3f}".format(p.frais) }}</td>
|
||||
<td class="py-2 px-3">{{ "{:,.3f}".format(p.prix_net) }}</td>
|
||||
<td class="py-2 px-3">
|
||||
{{ "{:,.3f}".format(p.prix_brut | abs) }}
|
||||
</td>
|
||||
<td class="py-2 px-3">{{ "{:,.3f}".format(p.frais | abs) }}</td>
|
||||
<td class="py-2 px-3">
|
||||
{{ "{:,.3f}".format(p.prix_net | abs) }}
|
||||
</td>
|
||||
<td class="py-2 px-3">{{ "{:,.3f}".format(p.eng_brut) }}</td>
|
||||
<td class="py-2 px-3">{{ "{:,.3f}".format(p.eng_net) }}</td>
|
||||
<td class="py-2 px-3">
|
||||
@@ -184,7 +193,7 @@
|
||||
<td class="py-2 px-3 text-end">
|
||||
<button
|
||||
type="button"
|
||||
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 }}')"
|
||||
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 }}')"
|
||||
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"
|
||||
>
|
||||
@@ -204,38 +213,42 @@
|
||||
</td>
|
||||
|
||||
{% 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 /
|
||||
groupe.total_quantite) | abs %} {% set total_frais_moyen =
|
||||
(total_prix_net_moyen - total_prix_brut_moyen) | abs %}
|
||||
groupe.total_quantite) %} {% set total_frais_moyen =
|
||||
(total_prix_net_moyen - total_prix_brut_moyen) %}
|
||||
|
||||
<td class="py-2.5 px-3">
|
||||
{{ "{:,.3f}".format(total_prix_brut_moyen) }}
|
||||
{{ "{:,.3f}".format(total_prix_brut_moyen | abs) }}
|
||||
</td>
|
||||
<td class="py-2.5 px-3">
|
||||
{{ "{:,.3f}".format(total_frais_moyen) }}
|
||||
{{ "{:,.3f}".format(total_frais_moyen | abs) }}
|
||||
</td>
|
||||
<td class="py-2.5 px-3">
|
||||
{{ "{:,.3f}".format(total_prix_net_moyen) }}
|
||||
{{ "{:,.3f}".format(total_prix_net_moyen | abs) }}
|
||||
</td>
|
||||
<td class="py-2.5 px-3">
|
||||
{{ "{:,.3f}".format(groupe.total_eng_brut | abs) }}
|
||||
{{ "{:,.3f}".format(groupe.total_eng_brut) }}
|
||||
</td>
|
||||
<td class="py-2.5 px-3">
|
||||
{{ "{:,.3f}".format(groupe.total_eng_net | abs) }}
|
||||
<span
|
||||
class="{% if groupe.total_eng_net >= 0 %}text-emerald-400{% else %}text-rose-400{% endif %}"
|
||||
>
|
||||
{{ "{:,.3f}".format(groupe.total_eng_net) }}
|
||||
</span>
|
||||
</td>
|
||||
{% else %}
|
||||
<td class="py-2.5 px-3">-</td>
|
||||
<td class="py-2.5 px-3">-</td>
|
||||
<td class="py-2.5 px-3">-</td>
|
||||
<td class="py-2.5 px-3">
|
||||
{{ "{:,.3f}".format(groupe.total_eng_brut | abs) }}
|
||||
{{ "{:,.3f}".format(groupe.total_eng_brut) }}
|
||||
</td>
|
||||
<td class="py-2.5 px-3">
|
||||
<span
|
||||
class="{% if groupe.total_eng_net >= 0 %}text-emerald-400{% else %}text-rose-400{% endif %}"
|
||||
>
|
||||
{{ "{:,.3f}".format(groupe.total_eng_net | abs) }}
|
||||
{{ "{:,.3f}".format(groupe.total_eng_net) }}
|
||||
</span>
|
||||
</td>
|
||||
{% endif %}
|
||||
@@ -257,6 +270,15 @@
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer fixe de la box -->
|
||||
<div
|
||||
class="px-6 py-3 border-t border-gray-800 text-xs text-gray-400 text-center shrink-0 bg-gray-900"
|
||||
>
|
||||
Positions en bourse : Accès sécurisé. Veillez à fermer votre session après
|
||||
chaque utilisation.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Popup Unique (Création d'ordre, Ajout de ligne, et Modification de ligne) -->
|
||||
@@ -282,9 +304,10 @@
|
||||
<form
|
||||
id="form-modal-ordre"
|
||||
method="POST"
|
||||
action="{{ url_for('main.creer_ordre_traitement') }}"
|
||||
action="{{ url_for('ordres.creer_ordre_traitement') }}"
|
||||
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 -->
|
||||
<input
|
||||
type="hidden"
|
||||
@@ -385,7 +408,7 @@
|
||||
type="number"
|
||||
step="0.0001"
|
||||
id="input-frais-brocker"
|
||||
name="frais_brocker"
|
||||
name="frais_broker"
|
||||
value="0.0000"
|
||||
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"
|
||||
@@ -576,7 +599,7 @@
|
||||
const btnValider = document.getElementById("btn-valider");
|
||||
|
||||
modal.classList.remove("hidden");
|
||||
form.action = "{{ url_for('main.creer_ordre_traitement') }}";
|
||||
form.action = "{{ url_for('ordres.creer_ordre_traitement') }}";
|
||||
title.innerHTML =
|
||||
'<i class="fa-solid fa-plus-circle text-emerald-500"></i> Créer un Nouvel Ordre';
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ content %}
|
||||
</p>
|
||||
</div>
|
||||
<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"
|
||||
>
|
||||
Retour au menu
|
||||
@@ -57,15 +57,15 @@ content %}
|
||||
<div class="overflow-y-auto flex-1 space-y-1 pr-1">
|
||||
{% for s in societes %}
|
||||
<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"
|
||||
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
|
||||
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>
|
||||
</div>
|
||||
{% endfor %}
|
||||
@@ -82,7 +82,8 @@ content %}
|
||||
action=""
|
||||
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" />
|
||||
|
||||
<div class="space-y-4">
|
||||
@@ -101,7 +102,7 @@ content %}
|
||||
<input
|
||||
type="text"
|
||||
id="field-nom"
|
||||
name="Nom"
|
||||
name="nom"
|
||||
required
|
||||
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"
|
||||
@@ -114,7 +115,7 @@ content %}
|
||||
<input
|
||||
type="text"
|
||||
id="field-forme"
|
||||
name="Forme"
|
||||
name="forme"
|
||||
required
|
||||
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"
|
||||
@@ -127,7 +128,7 @@ content %}
|
||||
<input
|
||||
type="number"
|
||||
id="field-capital"
|
||||
name="Capital"
|
||||
name="capital"
|
||||
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"
|
||||
/>
|
||||
@@ -174,7 +175,7 @@ content %}
|
||||
<input
|
||||
type="text"
|
||||
id="field-rcs"
|
||||
name="RCS"
|
||||
name="rcs"
|
||||
required
|
||||
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"
|
||||
@@ -189,7 +190,7 @@ content %}
|
||||
<input
|
||||
type="text"
|
||||
id="field-tva"
|
||||
name="TVA"
|
||||
name="tva"
|
||||
required
|
||||
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"
|
||||
@@ -204,7 +205,7 @@ content %}
|
||||
<input
|
||||
type="text"
|
||||
id="field-naf"
|
||||
name="NAF"
|
||||
name="naf"
|
||||
required
|
||||
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"
|
||||
@@ -219,7 +220,7 @@ content %}
|
||||
<input
|
||||
type="text"
|
||||
id="field-tel"
|
||||
name="Tel"
|
||||
name="tel"
|
||||
required
|
||||
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"
|
||||
@@ -234,7 +235,7 @@ content %}
|
||||
<input
|
||||
type="text"
|
||||
id="field-web"
|
||||
name="Web"
|
||||
name="web"
|
||||
required
|
||||
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"
|
||||
@@ -249,7 +250,7 @@ content %}
|
||||
<input
|
||||
type="email"
|
||||
id="field-mail"
|
||||
name="Mail"
|
||||
name="mail"
|
||||
required
|
||||
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"
|
||||
|
||||
@@ -34,7 +34,7 @@ content %}
|
||||
</p>
|
||||
</div>
|
||||
<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"
|
||||
>
|
||||
Retour au menu
|
||||
@@ -83,7 +83,8 @@ content %}
|
||||
action=""
|
||||
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" />
|
||||
|
||||
<div class="space-y-4">
|
||||
|
||||
@@ -17,7 +17,8 @@ block content %}
|
||||
</div>
|
||||
{% 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>
|
||||
<label for="login" class="block text-sm font-medium text-gray-300 mb-1"
|
||||
>Login :</label
|
||||
|
||||
@@ -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">
|
||||
<!-- btn 1 -->
|
||||
<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]"
|
||||
>
|
||||
1. Gérer les actions
|
||||
@@ -23,7 +23,7 @@ header_title %}Tableau de bord{% endblock %} {% block content %}
|
||||
|
||||
<!-- btn 2 -->
|
||||
<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]"
|
||||
>
|
||||
2. Ordres
|
||||
@@ -31,7 +31,7 @@ header_title %}Tableau de bord{% endblock %} {% block content %}
|
||||
|
||||
<!-- btn 3 -->
|
||||
<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]"
|
||||
>
|
||||
3. Comptes
|
||||
@@ -39,7 +39,7 @@ header_title %}Tableau de bord{% endblock %} {% block content %}
|
||||
|
||||
<!-- btn 4 -->
|
||||
<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]"
|
||||
>
|
||||
4. Gestion sociétés
|
||||
@@ -47,7 +47,7 @@ header_title %}Tableau de bord{% endblock %} {% block content %}
|
||||
|
||||
<!-- btn 5 -->
|
||||
<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]"
|
||||
>
|
||||
5. Gestion utilisateurs
|
||||
@@ -55,7 +55,7 @@ header_title %}Tableau de bord{% endblock %} {% block content %}
|
||||
|
||||
<!-- btn 6 -->
|
||||
<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]"
|
||||
>
|
||||
6. Stat historique
|
||||
@@ -63,7 +63,7 @@ header_title %}Tableau de bord{% endblock %} {% block content %}
|
||||
|
||||
<!-- btn 7 -->
|
||||
<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]"
|
||||
>
|
||||
7. Import/Export Actions CSV
|
||||
@@ -71,7 +71,7 @@ header_title %}Tableau de bord{% endblock %} {% block content %}
|
||||
|
||||
<!-- btn 8 -->
|
||||
<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]"
|
||||
>
|
||||
8. Import historique CSV
|
||||
|
||||
@@ -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
@@ -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
@@ -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()
|
||||
@@ -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é !")
|
||||
@@ -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
@@ -1,13 +1,10 @@
|
||||
services:
|
||||
db:
|
||||
image: mysql:8.0
|
||||
environment:
|
||||
MYSQL_ROOT_PASSWORD: sysadm-1963
|
||||
MYSQL_DATABASE: bolsa
|
||||
MYSQL_USER: jfgiraud
|
||||
MYSQL_PASSWORD: lyon4
|
||||
env_file: .env
|
||||
volumes:
|
||||
- db_data:/var/lib/mysql
|
||||
- ./migrations/schema.sql:/docker-entrypoint-initdb.d/01_schema.sql:ro
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
@@ -18,34 +15,39 @@ services:
|
||||
"127.0.0.1",
|
||||
"-u",
|
||||
"root",
|
||||
"-psysadm-1963",
|
||||
"-p${MYSQL_ROOT_PASSWORD}",
|
||||
]
|
||||
interval: 3s
|
||||
timeout: 3s
|
||||
retries: 10
|
||||
restart: unless-stopped
|
||||
|
||||
web:
|
||||
build: .
|
||||
command: python run.py
|
||||
env_file: .env
|
||||
environment:
|
||||
- FLASK_ENV=${FLASK_ENV}
|
||||
volumes:
|
||||
- .:/app
|
||||
# Montage restreint au code applicatif (pas .git / mysql_data / .env)
|
||||
- ./app:/app/app
|
||||
ports:
|
||||
- "5000:5000"
|
||||
environment:
|
||||
- DATABASE_URL=mysql+pymysql://jfgiraud:lyon4@db/bolsa
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
|
||||
phpmyadmin:
|
||||
image: phpmyadmin/phpmyadmin
|
||||
ports:
|
||||
- "8080:80"
|
||||
env_file: .env
|
||||
environment:
|
||||
PMA_HOST: db
|
||||
MYSQL_ROOT_PASSWORD: sysadm-1963
|
||||
ports:
|
||||
# Accès local uniquement (pas d'exposition publique)
|
||||
- "8080:80"
|
||||
depends_on:
|
||||
- db
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
db_data:
|
||||
|
||||
@@ -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()
|
||||
@@ -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;
|
||||
@@ -1,17 +1,25 @@
|
||||
blinker==1.9.0
|
||||
certifi==2026.7.22
|
||||
charset-normalizer==3.4.9
|
||||
cryptography
|
||||
click==8.4.2
|
||||
Flask==3.1.3
|
||||
Flask-SQLAlchemy==3.1.1
|
||||
Flask-WTF==1.2.1
|
||||
greenlet==3.5.4
|
||||
idna==3.18
|
||||
itsdangerous==2.2.0
|
||||
Jinja2==3.1.6
|
||||
MarkupSafe==3.0.3
|
||||
mysql-connector-python==9.7.0
|
||||
python-dotenv==1.1.1
|
||||
numpy
|
||||
pandas
|
||||
PyMySQL==1.1.1
|
||||
requests==2.34.2
|
||||
SQLAlchemy==2.0.51
|
||||
typing_extensions==4.16.0
|
||||
urllib3==2.7.0
|
||||
Werkzeug==3.1.8
|
||||
WTForms==3.1.2
|
||||
gunicorn
|
||||
|
||||
Reference in New Issue
Block a user