# kor-auto : webhook TradingView → Bitunix Futures (KORU)

Application PHP 8.3 qui reçoit les alertes de la stratégie Pine « KORU LONG/SHORT 2h v7 webhook »,
les enregistre dans MySQL et passe les ordres correspondants sur Bitunix Futures (KORUUSDT perpétuel),
en traçant chaque requête.

```
TradingView ──POST JSON──▶ Apache / PHP-FPM ──▶ webhook_signals (pending) ──▶ réponse 200 (< 3 s)
                                                        │
                                   bin/worker.php ◀─────┘  (service systemd, un signal à la fois)
                                        │
                                        ▶ Bitunix Futures : ordre au marché, stop de position, fermeture
                                        ▶ bitunix_requests, orders, positions
```

Sommaire :

1. [Arborescence](#arborescence)
2. [Schéma de base de données](#schéma-de-base-de-données)
3. [Fonctionnement](#fonctionnement)
4. [Installation](#installation)
5. [Variables d'environnement](#variables-denvironnement)
6. [Migrations](#migrations)
7. [Apache et HTTPS](#apache-et-https)
8. [Lancement du worker](#lancement-du-worker)
9. [Vérifier la configuration Bitunix](#vérifier-la-configuration-bitunix)
10. [Créer l'alerte TradingView](#créer-lalerte-tradingview)
11. [Passer de dry_run à live](#passer-de-dry_run-à-live)
12. [Lire les logs et les tables](#lire-les-logs-et-les-tables)
13. [API Bitunix : vérifications et choix prudents](#api-bitunix--vérifications-et-choix-prudents)
14. [Développement](#développement)

## Arborescence

```
.
├── bin/
│   ├── console                 # commandes : signals:list, bitunix:check
│   └── worker.php              # worker CLI (service systemd)
├── config/
│   ├── container.php           # définitions PHP-DI
│   └── routes.php              # routes et middlewares Slim
├── db/migrations/              # migrations Phinx (MySQL 8, InnoDB, utf8mb4)
├── deploy/
│   ├── apache-koru-webhook.conf
│   └── koru-worker.service
├── public/index.php            # point d'entrée HTTP (Slim 4)
├── src/
│   ├── Kernel.php              # construction du conteneur et de l'application Slim
│   ├── Action/                 # HTTP mince : action webhook, liste blanche d'IP, erreurs JSON
│   ├── Application/            # cas d'usage : réception, worker, traitement, taille, stop, configuration
│   ├── Console/                # commandes Symfony Console
│   ├── Domain/                 # objets métier immuables, enums, exceptions, interfaces des dépôts
│   └── Infrastructure/         # client Bitunix (live, dry_run), dépôts PDO, logs Monolog
├── tests/
│   ├── Unit/
│   ├── Integration/            # endpoint webhook de bout en bout avec Slim
│   └── Support/                # doublures : dépôts en mémoire, faux client Bitunix
├── var/log/                    # logs (rotation 30 jours)
├── var/cache/                  # cache des caractéristiques du symbole
├── .env.example
├── phinx.php
└── composer.json
```

Couches : `Action` (HTTP) → `Application` (cas d'usage) → `Domain` (métier) ← `Infrastructure` (Bitunix, MySQL, logs).
Le domaine ne dépend de rien ; l'infrastructure implémente les interfaces déclarées par le domaine et l'application.

## Schéma de base de données

Toutes les tables : InnoDB, `utf8mb4`, dates en UTC (`DATETIME(3)`). Les clés étrangères `signal_id`
pointent sur la clé unique `webhook_signals.signal_id`.

### `webhook_signals` : un message TradingView accepté

| Colonne | Type | Remarque |
|---|---|---|
| `id` | BIGINT UNSIGNED PK | ordre d'arrivée |
| `signal_id` | VARCHAR(100) UNIQUE | idempotence |
| `strategy`, `action`, `reason`, `ticker` | VARCHAR | |
| `bar_interval` | VARCHAR(10) | champ `interval` du message (`INTERVAL` est un mot réservé MySQL) |
| `price` | DECIMAL(30,12) | clôture de la bougie signal, indicatif |
| `stop_pct` | DECIMAL(10,4) NULL | valeur reçue, stockée seulement |
| `bar_time` | BIGINT | ms epoch |
| `payload` | JSON | message reçu **sans le champ `secret`** |
| `source_ip` | VARCHAR(45) | |
| `status` | VARCHAR(16) | `pending`, `processing`, `done`, `ignored`, `failed` |
| `error_message` | TEXT NULL | raison du statut final |
| `received_at`, `processing_started_at`, `processed_at` | DATETIME(3) | |

Index `(status, id)` pour la file du worker.

### `bitunix_requests` : chaque appel (réel ou simulé) à Bitunix

| Colonne | Type | Remarque |
|---|---|---|
| `id` | BIGINT UNSIGNED PK | |
| `signal_id` | VARCHAR(100) NULL, FK | signal en cours de traitement (NULL pour `bitunix:check`) |
| `dry_run` | TINYINT(1) | 1 = requête simulée, jamais envoyée |
| `method`, `endpoint`, `query` | VARCHAR | |
| `request_body` | TEXT NULL | corps JSON envoyé (exactement la chaîne signée) |
| `http_status` | SMALLINT NULL | |
| `bitunix_code` | INT NULL | champ `code` de la réponse |
| `response_body` | MEDIUMTEXT NULL | tronqué à 64 Ko |
| `duration_ms` | INT UNSIGNED | |
| `exception` | TEXT NULL | erreur réseau éventuelle |
| `created_at` | DATETIME(3) | |

Aucun en-tête (`api-key`, `sign`, `nonce`, `timestamp`) n'est stocké.

### `orders` : ordres passés (ouverture, stop, fermeture)

| Colonne | Type | Remarque |
|---|---|---|
| `id` | BIGINT UNSIGNED PK | |
| `signal_id` | VARCHAR(100) NULL, FK | |
| `client_id` | VARCHAR(40) NULL UNIQUE | dérivé du `signal_id`, ordres d'ouverture seulement |
| `order_id` | VARCHAR(64) NULL | id Bitunix |
| `position_id` | VARCHAR(64) NULL | |
| `symbol`, `side` (`BUY`/`SELL`), `trade_side` (`OPEN`/`CLOSE`) | VARCHAR | |
| `type` | VARCHAR(20) | `MARKET` (ouverture), `POSITION_SL` (stop), `FLASH_CLOSE` (fermeture) |
| `qty`, `trigger_price`, `avg_price`, `fee` | DECIMAL(30,12) NULL | `trigger_price` = prix du stop |
| `status` | VARCHAR(16) | `pending`, `placed`, `filled`, `failed`, `unknown` |
| `error_message` | TEXT NULL | |
| `mode` | VARCHAR(10) | `live` ou `dry_run` |
| `created_at`, `updated_at` | DATETIME(3) | |

### `positions` : positions ouvertes par le robot

| Colonne | Type | Remarque |
|---|---|---|
| `id` | BIGINT UNSIGNED PK | |
| `position_id` | VARCHAR(64) UNIQUE | id Bitunix (ou `dry-…`) |
| `symbol`, `side` (`LONG`/`SHORT`) | VARCHAR | |
| `qty`, `entry_price`, `stop_price`, `margin` | DECIMAL(30,12) | |
| `leverage` | INT | |
| `sl_order_id` | VARCHAR(64) NULL | |
| `exit_price`, `realized_pnl` | DECIMAL(30,12) NULL | si récupérés |
| `exit_reason` | VARCHAR(150) NULL | raison du signal, « stop », fermeture de sécurité… |
| `open_signal_id`, `close_signal_id` | VARCHAR(100) NULL, FK | |
| `mode` | VARCHAR(10) | `live` ou `dry_run` |
| `opened_at`, `closed_at` | DATETIME(3) | |

## Fonctionnement

### Réception (`POST /webhook/tradingview`)

Le endpoint ne fait qu'un `INSERT` : il valide, enregistre le signal en `pending` et répond aussitôt.

| Cas | Réponse |
|---|---|
| Signal accepté | `200 {"status":"accepted","signal_id":"…"}` |
| Même `signal_id` déjà reçu | `200 {"status":"duplicate","signal_id":"…"}` (rien n'est retraité) |
| Secret absent ou faux (`hash_equals`) | `401 {"status":"error","error":"unauthorized"}` |
| IP hors liste blanche (`REMOTE_ADDR`) | `403 {"status":"error","error":"forbidden"}` |
| JSON invalide, champ manquant, action inconnue, `strategy` ≠ `STRATEGY_ID` | `422 {"status":"error","error":"…"}` |
| Erreur interne (base indisponible…) | `500 {"status":"error","error":"internal error"}`, détail dans les logs |

L'IP est lue dans `REMOTE_ADDR` uniquement : un en-tête `X-Forwarded-For` est ignoré.

### Traitement (worker)

Le worker prend le plus ancien signal `pending` (`SELECT … FOR UPDATE SKIP LOCKED`, puis passage en `processing`),
le traite, puis le termine en `done`, `ignored` ou `failed`. Un signal `failed` n'est jamais rejoué.

**Ouverture (`open_long`, `open_short`)**

1. Lecture des positions ouvertes sur `BITUNIX_SYMBOL`. Une position existe déjà → `ignored` (« position déjà ouverte »),
   log `warning` (désynchronisation avec TradingView).
2. Signal reçu depuis plus de `SIGNAL_MAX_AGE_SECONDS` (worker arrêté longtemps, par exemple) → `ignored`, rien n'est ouvert.
3. Contrôles du symbole : `symbolStatus = OPEN`, `isApiSupported` différent de `false`, `LEVERAGE` ≤ `maxLeverage`.
4. Levier, mode de marge et mode de position comparés à la configuration et modifiés seulement s'ils diffèrent.
5. Taille : marge = min(disponible × `BALANCE_USAGE_RATIO`, `MAX_MARGIN_USDT`), position = marge × levier,
   quantité = position / `lastPrice` (endpoint public `tickers`), arrondie **vers le bas** à `basePrecision`.
   Sous `minTradeVolume` → `failed` avec le détail du calcul.
6. Ordre au marché (`BUY` pour un long, `SELL` pour un short, `tradeSide: OPEN`, `clientId` dérivé du `signal_id`).
7. Lecture de la position créée (5 tentatives espacées d'une seconde) : `positionId` et `avgOpenPrice`.
8. Stop de position à `STOP_LOSS_PCT` du prix d'entrée réel, arrondi à `quotePrecision` en rapprochant toujours le stop
   du prix d'entrée (vers le haut pour un long, vers le bas pour un short) : le stop n'est jamais élargi.
9. **Stop refusé 3 fois → fermeture immédiate de la position (`flash_close_position`) et log `critical`.**
   Toute autre erreur survenue après l'ouverture et avant la pose du stop déclenche la même fermeture de sécurité.
10. Récupération du prix moyen et des frais (`get_history_orders`), à titre d'information.

**Fermeture (`close_long`, `close_short`)**

1. Pas de position, ou position dans l'autre sens → `ignored` (cas normal quand le stop Bitunix a fermé la position
   avant le message `stop loss` de TradingView).
2. Sinon `flash_close_position` (3 tentatives, avec vérification de l'existence de la position entre deux tentatives),
   puis lecture du prix de sortie et du PnL (`get_history_positions`) si Bitunix les fournit.

Les positions encore ouvertes en base mais absentes de Bitunix (stop déclenché, fermeture manuelle) sont marquées
fermées à la lecture suivante, avec la raison « fermée sur Bitunix ».

### Erreurs réseau et nouvelles tentatives

- Délais HTTP : `BITUNIX_CONNECT_TIMEOUT` (5 s) et `BITUNIX_TIMEOUT` (10 s).
- Requêtes de lecture (GET) : une seule nouvelle tentative après une erreur réseau ou une limitation de débit.
- `place_order` n'est **jamais** renvoyé automatiquement. Si la réponse est perdue, le worker lit les positions :
  position trouvée → le traitement continue (stop posé) ; sinon recherche de l'ordre par `clientId`
  dans l'historique ; rien trouvé → `failed` (« résultat incertain »), sans nouvel envoi.
- Une réponse HTTP 5xx est traitée comme incertaine, de la même façon qu'une erreur réseau.

### Arrêt du worker

`SIGTERM` (arrêt systemd) : le signal en cours est terminé, puis le worker s'arrête. Le worker s'arrête aussi de
lui-même après `WORKER_MAX_RUNTIME_SECONDS` et sur erreur de base de données ; systemd le relance.
Un signal resté en `processing` après un arrêt brutal passe en `failed` au démarrage suivant, avec un log `critical`
(vérifier les positions Bitunix à la main), et n'est pas rejoué.

### Mode `dry_run`

Bitunix n'a ni testnet ni sandbox API (réponse officielle sur `BitunixOfficial/open-api`, issue #4). Avec
`BITUNIX_MODE=dry_run` :

- les données publiques (`trading_pairs`, `tickers`) sont lues pour de vrai ;
- **aucune requête privée n'est envoyée** : solde (`DRY_RUN_BALANCE_USDT` + PnL simulés − marges engagées),
  positions (table `positions`, mode `dry_run`), exécution au dernier prix public et stop sont simulés ;
- les requêtes qui auraient été envoyées sont tracées dans `bitunix_requests` avec `dry_run = 1` ;
- le stop simulé est vérifié à chaque lecture des positions (donc à chaque signal), contre le mark price ou le
  last price selon `SL_STOP_TYPE` ; le prix de sortie retenu est le prix du stop. Frais et funding ne sont pas simulés.

Le mode est écrit dans chaque ligne de log (`"mode":"dry_run"`), dans `orders.mode` et `positions.mode`,
et affiché par `bin/console bitunix:check`.

## Installation

Sur le VPS (Ubuntu, Apache 2.4 MPM event, PHP-FPM 8.3, MySQL 8) :

```bash
sudo apt install apache2 mysql-server php8.3-fpm php8.3-cli php8.3-mysql php8.3-bcmath \
    php8.3-curl php8.3-mbstring php8.3-xml unzip git composer certbot
sudo a2enmod ssl rewrite headers proxy proxy_fcgi setenvif

sudo git clone https://github.com/chaillouvincent/kor-auto.git /var/www/koru-webhook
cd /var/www/koru-webhook
sudo composer install --no-dev --optimize-autoloader

sudo cp .env.example .env
sudo nano .env            # voir la section suivante
sudo chown -R root:www-data /var/www/koru-webhook
sudo chmod 640 .env
sudo chown -R www-data:www-data var/
```

Le secret du webhook se génère avec `openssl rand -hex 32`.

Base MySQL :

```sql
CREATE DATABASE koru CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'koru'@'localhost' IDENTIFIED BY 'mot-de-passe-solide';
GRANT SELECT, INSERT, UPDATE, DELETE, CREATE, ALTER, DROP, INDEX, REFERENCES ON koru.* TO 'koru'@'localhost';
```

Vérifier que le serveur est à l'heure (Bitunix refuse un `timestamp` trop décalé, code `10011`) :
`timedatectl status` doit indiquer `System clock synchronized: yes`.

## Variables d'environnement

Toutes dans `.env` (voir `.env.example`, commenté). Aucune valeur secrète dans le dépôt.

| Variable | Défaut | Rôle |
|---|---|---|
| `APP_ENV` | `prod` | |
| `LOG_LEVEL` | `info` | `debug`, `info`, `notice`, `warning`, `error`, `critical` |
| `DB_HOST`, `DB_PORT`, `DB_NAME`, `DB_USER`, `DB_PASSWORD` | `127.0.0.1`, `3306`, `koru`, `koru`, vide | MySQL |
| `WEBHOOK_SECRET` | **obligatoire** | secret partagé avec l'alerte TradingView, 16 caractères minimum |
| `WEBHOOK_IP_WHITELIST_ENABLED` | `true` | liste blanche d'IP |
| `WEBHOOK_ALLOWED_IPS` | les 4 IP TradingView | séparées par des virgules |
| `STRATEGY_ID` | `koru-v7` | doit correspondre au champ `strategy` |
| `SIGNAL_MAX_AGE_SECONDS` | `900` | âge maximal d'un signal d'ouverture au moment de son traitement |
| `BITUNIX_MODE` | `dry_run` | `dry_run` ou `live` |
| `BITUNIX_BASE_URL` | `https://fapi.bitunix.com` | |
| `BITUNIX_API_KEY`, `BITUNIX_API_SECRET` | vide | obligatoires en `live` ; clé du sous-compte du robot |
| `BITUNIX_SYMBOL` | `KORUUSDT` | le symbole vient toujours d'ici, jamais du message |
| `BITUNIX_MARGIN_COIN` | `USDT` | |
| `BITUNIX_CONNECT_TIMEOUT`, `BITUNIX_TIMEOUT` | `5`, `10` | secondes |
| `SYMBOL_CACHE_TTL_SECONDS` | `14400` | cache de `trading_pairs` (4 h) |
| `LEVERAGE` | `3` | |
| `MARGIN_MODE` | `ISOLATION` | `ISOLATION` ou `CROSS` |
| `POSITION_MODE` | `ONE_WAY` | `ONE_WAY` ou `HEDGE` |
| `MAX_MARGIN_USDT` | `2000` | plafond de marge par trade, hors levier |
| `BALANCE_USAGE_RATIO` | `0.98` | part du solde disponible utilisée |
| `STOP_LOSS_PCT` | `7` | stop en % du prix d'entrée réel |
| `SL_STOP_TYPE` | `MARK_PRICE` | `MARK_PRICE` ou `LAST_PRICE` |
| `DRY_RUN_BALANCE_USDT` | `240` | solde de départ simulé |
| `WORKER_POLL_INTERVAL_MS` | `1000` | pause quand la file est vide |
| `WORKER_MAX_RUNTIME_SECONDS` | `3600` | arrêt propre périodique (systemd relance) |
| `TELEGRAM_BOT_TOKEN`, `TELEGRAM_CHAT_ID` | vide | alertes `critical` sur Telegram si les deux sont définis |

La clé API ne sert qu'au compte de la clé : l'application n'utilise jamais `subAccountId` / `includeSubAccounts`
et ne fait aucun transfert. Permissions de la clé : trading futures uniquement, ni retrait ni transfert,
liée à l'IP du serveur.

Après toute modification de `.env` : `sudo systemctl restart koru-worker php8.3-fpm`.

## Migrations

```bash
cd /var/www/koru-webhook
sudo -u www-data vendor/bin/phinx migrate      # crée les 4 tables
sudo -u www-data vendor/bin/phinx status
```

`phinx.php` lit les paramètres `DB_*` du fichier `.env`.

## Apache et HTTPS

Exemple complet : `deploy/apache-koru-webhook.conf` (port 80 réservé à Let's Encrypt et à la redirection,
port 443 pour le webhook ; `AllowOverride None`, la réécriture de `/webhook/tradingview` vers `index.php`
est dans le vhost, `/.well-known/acme-challenge/` est servi depuis `/var/www/letsencrypt`).

```bash
sudo mkdir -p /var/www/letsencrypt/.well-known/acme-challenge
sudo cp deploy/apache-koru-webhook.conf /etc/apache2/sites-available/koru-webhook.conf
sudo nano /etc/apache2/sites-available/koru-webhook.conf     # nom de domaine ou IP, chemins
sudo a2ensite koru-webhook
# Premier certificat : activer d'abord le seul vhost 80 (commenter le bloc 443), puis
sudo certbot certonly --webroot -w /var/www/letsencrypt -d koru.example.com
sudo apachectl configtest && sudo systemctl reload apache2
```

TradingView n'accepte que les ports 80 et 443. L'URL de l'alerte doit être directement en `https://` :
une redirection 301 d'une requête POST n'est pas fiable.

## Lancement du worker

```bash
sudo cp deploy/koru-worker.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now koru-worker
systemctl status koru-worker
journalctl -u koru-worker -f
```

Un seul worker suffit (les signaux sont traités un par un, dans l'ordre d'arrivée). Le verrou `SKIP LOCKED`
empêche toutefois deux workers de prendre le même signal.

## Vérifier la configuration Bitunix

```bash
sudo -u www-data php bin/console bitunix:check
sudo -u www-data php bin/console bitunix:check --private    # en dry_run : teste aussi la clé API
```

La commande n'envoie que des requêtes de lecture. Elle affiche le mode, les caractéristiques du symbole
(`symbolStatus`, `isApiSupported`, `minTradeVolume`, précisions, levier maximal), les prix, puis, en `live`
ou avec `--private`, le solde disponible, le levier et les modes actuels, les positions ouvertes et la taille
du prochain ordre. Codes fréquents : `10004` IP du serveur absente de la clé, `10007` signature (secret mal copié),
`10011` horloge du serveur.

Derniers signaux :

```bash
sudo -u www-data php bin/console signals:list
sudo -u www-data php bin/console signals:list --status=failed --limit=50
```

Test manuel du webhook depuis une IP autorisée (ou avec `WEBHOOK_IP_WHITELIST_ENABLED=false` le temps du test) :

```bash
curl -s https://koru.example.com/webhook/tradingview -H 'Content-Type: application/json' -d '{
  "secret": "VOTRE_SECRET",
  "order": {"v":1,"strategy":"koru-v7","signal_id":"test-manuel-1","action":"close_long",
            "reason":"test","ticker":"KORUUSDT.P","interval":"120","price":1.2345,"stop_pct":7,
            "bar_time":1760601600000}
}'
```

## Créer l'alerte TradingView

1. Graphique KORUUSDT perpétuel Bitunix en 2h, script « KORU LONG/SHORT 2h v7 webhook » ajouté.
2. Nouvelle alerte : condition = la stratégie, déclenchement sur les exécutions d'ordres.
3. Message (le secret reste dans l'alerte, jamais dans le script) :
   ```
   {"secret":"VOTRE_SECRET","order":{{strategy.order.alert_message}}}
   ```
4. Notifications : Webhook URL = `https://<serveur>/webhook/tradingview`.
5. Expiration : sans limite. Après une modification du script ou de ses réglages, supprimer et recréer l'alerte.

Les webhooks TradingView demandent un abonnement payant et la double authentification activée.

## Passer de dry_run à live

1. Laisser tourner en `dry_run` quelques jours ; comparer `webhook_signals`, `orders` et `positions` (mode `dry_run`)
   avec les étiquettes de la stratégie sur le graphique.
2. Renseigner `BITUNIX_API_KEY` et `BITUNIX_API_SECRET` (clé du sous-compte), lancer `bitunix:check --private`.
3. Vérifier qu'aucune position ni aucun ordre n'est ouvert sur le sous-compte (le changement de mode de marge ou de
   position est refusé sinon, codes `20006` / `20009`).
4. Premier palier avec une petite mise : `MAX_MARGIN_USDT` juste au-dessus du minimum affiché par `bitunix:check`.
5. Dans `.env` : `BITUNIX_MODE=live`, puis `sudo systemctl restart koru-worker php8.3-fpm`.
6. `bitunix:check` doit afficher « live (ordres réels) ». Vérifier sur Bitunix un cycle complet :
   ouverture, stop visible sur la position, fermeture.
7. Remonter `MAX_MARGIN_USDT` à la valeur voulue et redémarrer le worker.

Les positions `dry_run` restent en base mais ne sont jamais prises en compte en `live` (filtrage par `mode`).

## Lire les logs et les tables

Logs (une ligne JSON de contexte par événement, secrets masqués, rotation sur 30 jours) :

```bash
tail -f /var/www/koru-webhook/var/log/app-$(date -u +%F).log       # réception, traitement, décisions
tail -f /var/www/koru-webhook/var/log/bitunix-$(date -u +%F).log   # chaque requête Bitunix
grep -h '\.CRITICAL' /var/www/koru-webhook/var/log/*.log            # à surveiller en priorité
journalctl -u koru-worker --since today
```

Le processeur de masquage remplace par `***` la clé API, le secret API, le secret du webhook, le mot de passe MySQL
et le jeton Telegram partout où ils apparaîtraient, ainsi que toute valeur de contexte nommée `secret`, `api-key`,
`sign`, `signature`, `token` ou `password`.

Requêtes utiles :

```sql
-- Derniers signaux
SELECT id, received_at, signal_id, action, reason, status, error_message
FROM webhook_signals ORDER BY id DESC LIMIT 20;

-- Ce qui a été envoyé à Bitunix pour un signal
SELECT created_at, dry_run, method, endpoint, query, request_body, http_status, bitunix_code, duration_ms, exception
FROM bitunix_requests WHERE signal_id = 'koru-v7-1760601600000-open_long' ORDER BY id;

-- Ordres et positions
SELECT * FROM orders ORDER BY id DESC LIMIT 20;
SELECT position_id, side, qty, entry_price, stop_price, exit_price, realized_pnl, exit_reason, mode, opened_at, closed_at
FROM positions ORDER BY id DESC LIMIT 20;

-- Erreurs Bitunix des dernières 24 h
SELECT created_at, endpoint, bitunix_code, response_body FROM bitunix_requests
WHERE (bitunix_code <> 0 OR exception IS NOT NULL) AND created_at > UTC_TIMESTAMP() - INTERVAL 1 DAY;
```

## API Bitunix : vérifications et choix prudents

Le site de documentation (`openapidoc.bitunix.com`, `www.bitunix.com/api-docs`) et `fapi.bitunix.com` n'étaient pas
joignables depuis l'environnement où le code a été écrit. Le client a donc été vérifié contre le **SDK officiel**
[`BitunixOfficial/open-api`](https://github.com/BitunixOfficial/open-api) (démos PHP, Python, Go et Java, commit
`0be792c` du 5 août 2025), qui renvoie lui-même vers cette documentation. **À relire sur la documentation en ligne
avant le passage en `live`** ; `bitunix:check --private` valide la signature et les lectures sans passer d'ordre.

Conforme au résumé fourni :

- URL `https://fapi.bitunix.com`, en-têtes `api-key`, `nonce`, `timestamp` (ms), `sign`, `Content-Type`, `language: en-US`.
- `digest = SHA256(nonce + timestamp + apiKey + queryParams + body)`, `sign = SHA256(digest + secretKey)` ;
  paramètres de query triés par clé et concaténés sans séparateur ; corps JSON compact, sérialisé une seule fois
  et signé tel qu'envoyé. Le test unitaire utilise le vecteur officiel du SDK Go
  (`open_api_http_sign_test.go` : résultat `40e87256…f937f922`) et l'exemple `id1uid200` de la documentation.
- Erreurs métier en HTTP 200 avec `code != 0` : toujours testées.
- Endpoints et champs : `trading_pairs`, `tickers`, `account`, `get_pending_positions`, `change_leverage`,
  `change_margin_mode`, `change_position_mode`, `place_order`, `tpsl/position/place_order`,
  `flash_close_position`, `get_history_orders` existent dans le SDK avec les champs utilisés.
- `slStopType` : le SDK Java définit `MARK_PRICE` / `LAST_PRICE` (valeurs retenues).

Différences ou points ambigus, et choix retenus :

| Point | Constat | Choix |
|---|---|---|
| `isApiSupported` | Absent du modèle `TradingPair` du SDK Java | Refus si le champ vaut `false` ; contrôle ignoré (log `notice`) s'il est absent |
| Codes `10010`, `10011` | Absents de la liste d'erreurs du SDK, qui ajoute `10005` / `10006` (limitation de débit), `20009` (mode de position non modifiable avec une position), `30042` (clientId en double) | Tous gérés ; `10005` / `10006` / HTTP 429 traités comme limitation de débit |
| Lecture du levier et du mode de marge | Le SDK expose `GET /api/v1/futures/account/get_leverage_margin_mode` | Utilisé pour ne modifier levier et marge que s'ils diffèrent |
| `slStopType` | La démo PHP du SDK mentionne `MARK` / `LAST` pour `tpStopType` dans un commentaire | `MARK_PRICE` / `LAST_PRICE` (enum du SDK Java), configurable par `SL_STOP_TYPE` |
| `flash_close_position` | Le SDK Java accepte aussi `marginCoin` | Seul `positionId` est envoyé, comme dans le résumé |
| Format de `clientId` | Longueur et caractères autorisés non trouvés | 32 caractères alphanumériques : `kv` + début du SHA-256 du `signal_id` (stable, donc détectable) |
| Méthode des lectures | Le SDK Java envoie certaines lectures en POST | GET, comme la documentation |
| `tradeSide` en `ONE_WAY` | Requis seulement en `HEDGE` | Toujours envoyé (`OPEN`), comme le fait la démo PHP officielle |
| Réponse de `account` | Objet dans le SDK | Objet ou liste d'un élément acceptés |
| Réponses d'historique | `get_history_orders` → `orderList`, `get_history_positions` → `positionList` | Utilisés pour l'information seulement ; leur absence n'empêche rien |
| Valeurs vides en query | Le SDK Java les exclut de la signature | Jamais envoyées |
| HTTP 5xx | Non documenté | Traité comme une issue incertaine : pas de nouvel envoi d'un POST |
| Erreur réseau lors de la pose du stop | Le stop a pu être posé malgré tout | Comptée comme un échec : après 3 échecs, la position est fermée (choix le plus sûr) |

## Développement

PHP 8.3 avec `bcmath`, `pdo_mysql`, `curl`, `mbstring`.

```bash
composer install
composer test      # PHPUnit : tests unitaires et d'intégration (aucun appel réel à Bitunix)
composer stan      # PHPStan niveau 8
composer cs        # PHP-CS-Fixer (PSR-12), sans modification
composer cs-fix    # applique les corrections
```

Tests principaux :

- `tests/Unit/Bitunix/BitunixSignerTest.php` : signature (vecteur officiel), tri de la query.
- `tests/Unit/Bitunix/BitunixTransportTest.php` : corps signé identique au corps envoyé, `code != 0`,
  une seule nouvelle tentative en lecture, jamais en écriture, aucun en-tête dans la trace.
- `tests/Unit/Bitunix/DryRunBitunixClientTest.php` : aucune requête signée en `dry_run`, solde et stop simulés.
- `tests/Unit/Trading/*` : taille de position et arrondis, prix du stop long et short, `clientId`.
- `tests/Unit/Signal/SignalParserTest.php`, `tests/Unit/Webhook/ReceiveSignalServiceTest.php` : validation, idempotence.
- `tests/Unit/Worker/SignalProcessorTest.php` : position déjà ouverte, fermeture sans position ou dans l'autre sens,
  stop refusé → fermeture de sécurité, réponse perdue sur `place_order`, réconciliation après un stop Bitunix.
- `tests/Integration/WebhookEndpointTest.php` : secret faux, IP refusée, JSON invalide, doublon, cas nominal,
  erreur interne sans fuite de détail.
