mirror of
https://github.com/artemium428/tvsignals-to-tg.git
synced 2026-09-15 17:16:21 +00:00
Replace TradingView polling with a local LTF/FVG scanner that posts Telegram cards and Heryon webhooks.
Per-strategy Heryon accounts, reversal captions with previous-trade path on charts, and Telegram replies chained by ticker. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
c2571f4558
commit
cdbda8fea3
24 changed files with 2125 additions and 196 deletions
19
.env.example
19
.env.example
|
|
@ -1,7 +1,26 @@
|
||||||
TELEGRAM_BOT_TOKEN=123456:ABC-DEF
|
TELEGRAM_BOT_TOKEN=123456:ABC-DEF
|
||||||
TELEGRAM_CHAT_ID=-1001234567890
|
TELEGRAM_CHAT_ID=-1001234567890
|
||||||
# Default forum topic when webhook URL has no /{thread_id} segment
|
# Default forum topic when webhook URL has no /{thread_id} segment
|
||||||
|
# and watchlist strategy telegram_thread_id is null
|
||||||
TELEGRAM_MESSAGE_THREAD_ID=1
|
TELEGRAM_MESSAGE_THREAD_ID=1
|
||||||
WEBHOOK_SECRET=change-me-to-a-long-random-string
|
WEBHOOK_SECRET=change-me-to-a-long-random-string
|
||||||
HOST=0.0.0.0
|
HOST=0.0.0.0
|
||||||
PORT=8000
|
PORT=8000
|
||||||
|
|
||||||
|
# Local scanner (replaces TradingView alerts)
|
||||||
|
SCANNER_ENABLED=true
|
||||||
|
SCANNER_POLL_SECONDS=20
|
||||||
|
WATCHLIST_PATH=watchlist.yaml
|
||||||
|
SCANNER_STATE_PATH=data/scanner.db
|
||||||
|
RISK_USD=40
|
||||||
|
|
||||||
|
# Heryon / Geryon — same JSON as Pine alertcondition (leave URL empty to skip)
|
||||||
|
GERYON_WEBHOOK_URL=
|
||||||
|
GERYON_ORDER_TYPE=bbo
|
||||||
|
# Per-strategy account/secret (fallback: GERYON_ACCOUNT_ID / GERYON_SECRET)
|
||||||
|
GERYON_ACCOUNT_ID_LTF=
|
||||||
|
GERYON_SECRET_LTF=
|
||||||
|
GERYON_ACCOUNT_ID_FVG=
|
||||||
|
GERYON_SECRET_FVG=
|
||||||
|
GERYON_ACCOUNT_ID=admin1
|
||||||
|
GERYON_SECRET=
|
||||||
|
|
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -8,3 +8,5 @@ __pycache__/
|
||||||
.pytest_cache/
|
.pytest_cache/
|
||||||
.mypy_cache/
|
.mypy_cache/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
data/*.db
|
||||||
|
data/*.db-*
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,9 @@ COPY requirements.txt .
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
COPY app ./app
|
COPY app ./app
|
||||||
|
COPY watchlist.yaml .
|
||||||
|
|
||||||
|
RUN mkdir -p /app/data
|
||||||
|
|
||||||
ENV PYTHONUNBUFFERED=1
|
ENV PYTHONUNBUFFERED=1
|
||||||
ENV MPLBACKEND=Agg
|
ENV MPLBACKEND=Agg
|
||||||
|
|
|
||||||
159
README.md
159
README.md
|
|
@ -1,10 +1,12 @@
|
||||||
# TradingView → Telegram setup service
|
# Signals → Telegram + Heryon
|
||||||
|
|
||||||
Accepts TradingView webhook alerts, renders a Binance Futures candlestick setup chart, and posts photo + caption into a Telegram forum topic.
|
Runs the 15m LTF and 6h FVG indicators on a Binance Futures watchlist, posts setup charts to a Telegram forum topic, and forwards the same Heryon webhook JSON that TradingView `alertcondition` used to send.
|
||||||
|
|
||||||
|
Inbound `POST /h/...` is still accepted for manual/debug payloads. Those requests are **not** forwarded to Heryon (avoids doubles if TradingView is still on).
|
||||||
|
|
||||||
## Quick start (Docker / VPS)
|
## Quick start (Docker / VPS)
|
||||||
|
|
||||||
1. Copy env and fill Telegram values:
|
1. Copy env and fill Telegram + Heryon values:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
|
|
@ -14,33 +16,33 @@ cp .env.example .env
|
||||||
TELEGRAM_BOT_TOKEN=...
|
TELEGRAM_BOT_TOKEN=...
|
||||||
TELEGRAM_CHAT_ID=-100...
|
TELEGRAM_CHAT_ID=-100...
|
||||||
TELEGRAM_MESSAGE_THREAD_ID=...
|
TELEGRAM_MESSAGE_THREAD_ID=...
|
||||||
HOST=0.0.0.0
|
WEBHOOK_SECRET=...
|
||||||
PORT=8000
|
GERYON_WEBHOOK_URL=http://geryon:PORT/path # empty = Telegram only
|
||||||
|
GERYON_ORDER_TYPE=bbo
|
||||||
|
GERYON_ACCOUNT_ID_LTF=...
|
||||||
|
GERYON_SECRET_LTF=...
|
||||||
|
GERYON_ACCOUNT_ID_FVG=...
|
||||||
|
GERYON_SECRET_FVG=...
|
||||||
```
|
```
|
||||||
|
|
||||||
2. Build and run:
|
2. Edit [`watchlist.yaml`](watchlist.yaml): symbols and optional `telegram_thread_id` per strategy (15m vs 6h topics).
|
||||||
|
|
||||||
|
3. Build and run:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
mkdir -p data
|
||||||
docker compose up -d --build
|
docker compose up -d --build
|
||||||
```
|
```
|
||||||
|
|
||||||
3. Health check:
|
4. Health check:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl http://127.0.0.1:8000/health
|
curl http://127.0.0.1:8000/health
|
||||||
```
|
```
|
||||||
|
|
||||||
4. Put HTTPS in front (nginx/Caddy) and point TradingView webhook to:
|
On first start the scanner **primes** the last closed bar per symbol and does not replay history. The next closed 15m / 6h bar can fire a signal.
|
||||||
|
|
||||||
`https://your-domain/h/<WEBHOOK_SECRET>`
|
After it is stable, turn off the TradingView alerts that used to hit this service and Heryon.
|
||||||
|
|
||||||
or, for a specific forum topic:
|
|
||||||
|
|
||||||
`https://your-domain/h/<WEBHOOK_SECRET>/<TELEGRAM_MESSAGE_THREAD_ID>`
|
|
||||||
|
|
||||||
Same alert message body; different URLs → different topics (e.g. one alert per timeframe). Without a thread segment, the env `TELEGRAM_MESSAGE_THREAD_ID` is used.
|
|
||||||
|
|
||||||
Bot must be added to the group/forum and allowed to post in the target topic.
|
|
||||||
|
|
||||||
## Local run (without Docker)
|
## Local run (without Docker)
|
||||||
|
|
||||||
|
|
@ -49,18 +51,53 @@ python -m venv .venv
|
||||||
source .venv/bin/activate
|
source .venv/bin/activate
|
||||||
pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
cp .env.example .env # fill values
|
cp .env.example .env # fill values
|
||||||
|
mkdir -p data
|
||||||
uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
|
uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
|
||||||
```
|
```
|
||||||
|
|
||||||
## TradingView alert JSON
|
## Scanner
|
||||||
|
|
||||||
The webhook accepts a **single-line or pretty-printed JSON** body, including TradingView’s common `Content-Type: text/plain`.
|
| Strategy | Timeframe | Risk | Heryon TPs |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `ltf` (`428th_v16_LTF`) | 15m + 6h filters | SL = 16h low/high; TP 1.75 / 2 / 4 R | `tp1/2/3_fix` 30% / 30% / 40%, `sl_to_bk=tp1` |
|
||||||
|
| `fvg` (`428th_v16`) | 6h | live SL 2R from ATR×1 stick; TP1 2R | `tp1_fix` 30%, empty tp2/tp3 (leftover until reverse) |
|
||||||
|
|
||||||
Static example (alert **Webhook message** body) — primary setup (`signal_sequence: 1`):
|
- Poll interval: `SCANNER_POLL_SECONDS` (default 20). Only **closed** candles are evaluated.
|
||||||
|
- State: `data/scanner.db` (last bar + Heryon nonces). Restart does not re-send the primed bar.
|
||||||
|
- Same-side lock matches live Pine (`open_side` on LTF, `use_lock_until_sl` on FVG).
|
||||||
|
- Tickers in Telegram / Heryon: `{SYMBOL}.P` (e.g. `BTCUSDT.P`).
|
||||||
|
- Size: `round(RISK_USD / abs(entry−sl)/entry)` with `RISK_USD=40`.
|
||||||
|
|
||||||
|
Set `SCANNER_ENABLED=false` to run as a webhook-only Telegram bridge.
|
||||||
|
|
||||||
|
### Heryon payload (LTF)
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"ticker": "{{ticker}}",
|
"action": "buy",
|
||||||
|
"ticker": "BTCUSDT.P",
|
||||||
|
"account_id": "admin1",
|
||||||
|
"order_type": "bbo",
|
||||||
|
"position_size_usd": "5000",
|
||||||
|
"stop_loss": "...",
|
||||||
|
"tp1": "...", "tp1_fix": "30%",
|
||||||
|
"tp2": "...", "tp2_fix": "30%",
|
||||||
|
"tp3": "...", "tp3_fix": "40%",
|
||||||
|
"sl_to_bk": "tp1",
|
||||||
|
"nonce": "BTCUSDT.P-limit-long-1721736000",
|
||||||
|
"secret": "..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
FVG omits `tp2` / `tp3` and their `_fix` fields. Empty string keys are not sent.
|
||||||
|
|
||||||
|
## Manual / debug webhook
|
||||||
|
|
||||||
|
`POST /h/<WEBHOOK_SECRET>` or `POST /h/<WEBHOOK_SECRET>/<thread_id>` still accepts the Telegram JSON body (including TradingView `text/plain`). Telegram only — no Heryon forward.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ticker": "BTCUSDT.P",
|
||||||
"action": "long",
|
"action": "long",
|
||||||
"entry_price": "65034.7",
|
"entry_price": "65034.7",
|
||||||
"current_price": "65034.7",
|
"current_price": "65034.7",
|
||||||
|
|
@ -74,84 +111,36 @@ Static example (alert **Webhook message** body) — primary setup (`signal_seque
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Control update (`signal_sequence` > 1) — Pine freezes `entry_price` / SL / TPs / `signal_time` from seq 1 and sends live `current_price`:
|
`take_profit_2_price` / `take_profit_3_price` are optional (omit or `""` for TP1-only FVG-style setups).
|
||||||
|
|
||||||
```json
|
Caption emoji/labels are built from `action` + `signal_sequence`.
|
||||||
{
|
|
||||||
"ticker": "{{ticker}}",
|
|
||||||
"action": "short",
|
|
||||||
"entry_price": "65034.7",
|
|
||||||
"current_price": "64000.1",
|
|
||||||
"stop_loss_price": "65896.8",
|
|
||||||
"take_profit_1_price": "63904.9",
|
|
||||||
"take_profit_2_price": "62781.6",
|
|
||||||
"take_profit_3_price": "61635.3",
|
|
||||||
"visual_timeframe": "15",
|
|
||||||
"signal_sequence": 2,
|
|
||||||
"signal_time": 1721736000
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### PineScript `alert()` (recommended)
|
|
||||||
|
|
||||||
Build the JSON inside `alert()`. A continuous one-line string is fine.
|
|
||||||
|
|
||||||
In the TradingView alert dialog:
|
|
||||||
|
|
||||||
- Webhook URL: `https://your-domain/h/<WEBHOOK_SECRET>` or `https://your-domain/h/<WEBHOOK_SECRET>/<thread_id>` (per-topic; same message body)
|
|
||||||
- Message: only `{{alert_message}}` (do not paste a second JSON next to it)
|
|
||||||
|
|
||||||
On **seq == 1**: store `entry_price = close`, freeze SL/TPs, and `signal_time = time / 1000` (bar open, unix seconds). On **seq > 1**: keep those frozen fields; only refresh `current_price` (= live `close`). Example shape:
|
|
||||||
|
|
||||||
```pinescript
|
|
||||||
alert('{"ticker":"' + syminfo.ticker + '","action":"long","entry_price":"' + str.tostring(long_entry_price, format.mintick) + '","current_price":"' + str.tostring(close, format.mintick) + '","stop_loss_price":"' + str.tostring(long_sl_price, format.mintick) + '","take_profit_1_price":"' + str.tostring(long_tp1_price, format.mintick) + '","take_profit_2_price":"' + str.tostring(long_tp2_price, format.mintick) + '","take_profit_3_price":"' + str.tostring(long_tp3_price, format.mintick) + '","visual_timeframe":"' + timeframe.period + '","signal_sequence":' + str.tostring(buyCount) + ',"signal_time":' + str.tostring(signal_buy_time) + '}', alert.freq_once_per_bar_close)
|
|
||||||
```
|
|
||||||
|
|
||||||
(Same idea for shorts with `short_entry_price` / `sellCount` / `signal_sell_time`.) Caption emoji/labels are built by the service from `action` + `signal_sequence` — do not put them in the webhook JSON.
|
|
||||||
|
|
||||||
Field notes:
|
|
||||||
|
|
||||||
| Field | Description |
|
| Field | Description |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `ticker` | any common TV form (`BTCUSDT.P`, `BTCUSDT`, `BINANCE:ETHUSDT`, `BTC/USDT`, …) → normalized to Binance Futures symbol for the chart; caption keeps the original |
|
| `ticker` | TV form (`BTCUSDT.P`, `BINANCE:ETHUSDT`, …) → Binance Futures for the chart; caption keeps the original |
|
||||||
| `action` | `long` or `short` |
|
| `action` | `long` or `short` |
|
||||||
| `entry_price` | trade entry from seq 1 (equals `current_price` on primary signal) |
|
| `*_price` | strings; TP2/TP3 optional |
|
||||||
| `current_price` | live price (`close` at alert time) |
|
| `visual_timeframe` | `15`, `360`, `15m`, `6h`, … |
|
||||||
| `*_price` | strings with your display precision |
|
| `signal_sequence` | `1` = setup; `>1` = control update |
|
||||||
| `visual_timeframe` | `1`, `3`, `5`, `15`, `30`, `60`, `120`, `240`, `D`, `W` (also `15m`, `1h`, …) |
|
| `signal_time` | unix seconds of the seq-1 bar open (UTC) |
|
||||||
| `signal_sequence` | `1` = primary setup; `>1` = control update of that trade |
|
|
||||||
| `signal_time` | unix seconds of the seq-1 bar open (UTC); chart draws Entry/SL/TP zones from that candle |
|
|
||||||
|
|
||||||
## Caption format
|
## Caption format
|
||||||
|
|
||||||
**seq `1` (setup):**
|
**seq `1` (setup):** `💚 Buy` / `💔 Sell` — Price / SL (risk %) / TP1 and TP2/TP3 when present.
|
||||||
|
|
||||||
- long: `BTCUSDT.P 💚 Buy`
|
**seq `>1` (control):** `🌱 Buy Seq: N` / `🥀 Sell Seq: N` — Entry / live Price / `Current profit`.
|
||||||
- short: `BTCUSDT.P 💔 Sell`
|
|
||||||
- body: `Price` / `SL (risk %)` / `TP1–3`
|
|
||||||
|
|
||||||
**seq `>1` (control):**
|
Prices: `$` and thousand spaces (`65034.7` → `$65 034.7`).
|
||||||
|
|
||||||
- long: `BTCUSDT.P 🌱 Buy Seq: N`
|
|
||||||
- short: `BTCUSDT.P 🥀 Sell Seq: N`
|
|
||||||
- body: `Entry price` (from seq 1) / live `Price` / `Current profit: +1.6% (RR 1:1.2)`
|
|
||||||
- no new SL/TP lines in the caption
|
|
||||||
|
|
||||||
Prices are shown with `$` and thousand spaces (`65034.7` → `$65 034.7`).
|
|
||||||
For seq 1, SL includes distance from entry: `SL: $63 904.9 (-2.37%)` (risk %, negative for both long and short).
|
|
||||||
For seq >1, profit % is signed vs entry; RR is `|price−entry| / |entry−SL|` with the same sign as profit.
|
|
||||||
|
|
||||||
## Behavior
|
## Behavior
|
||||||
|
|
||||||
1. Validate secret + payload, then immediately respond `200` (`{"ok": true, "accepted": true}`) so TradingView does not time out
|
1. Scanner (if enabled): closed bar → indicator → Telegram chart + Heryon JSON
|
||||||
2. In the background: fetch ~90 klines from Binance USDT-M Futures (public, no API key)
|
2. Inbound webhook: validate secret, `200` immediately, background Telegram only
|
||||||
3. Render PNG: candles + Entry / SL / TP1–3 from the payload, starting at the `signal_time` candle (seq `>1` reuses frozen seq-1 levels/time and also marks live `Price`)
|
3. Chart: ~90 USDT-M klines; on failure, text-only Telegram
|
||||||
4. `sendPhoto` to `TELEGRAM_CHAT_ID` topic from URL path or env `TELEGRAM_MESSAGE_THREAD_ID`
|
4. Telegram or Heryon failure is logged (webhook HTTP already returned `200`)
|
||||||
5. If chart/klines fail → text-only `sendMessage` fallback
|
|
||||||
6. If Telegram fails → logged only (HTTP already returned `200`)
|
|
||||||
|
|
||||||
## Endpoints
|
## Endpoints
|
||||||
|
|
||||||
- `GET /health` → `{"status":"ok"}`
|
- `GET /health` → `{"status":"ok"}`
|
||||||
- `POST /h/<WEBHOOK_SECRET>` → signal payload above; topic from env `TELEGRAM_MESSAGE_THREAD_ID` (wrong/missing secret → `404`)
|
- `POST /h/<WEBHOOK_SECRET>` → debug payload; topic from `TELEGRAM_MESSAGE_THREAD_ID`
|
||||||
- `POST /h/<WEBHOOK_SECRET>/<thread_id>` → same payload; topic from path (`thread_id` must be `>= 1`)
|
- `POST /h/<WEBHOOK_SECRET>/<thread_id>` → same; topic from path (`thread_id` >= 1)
|
||||||
|
|
|
||||||
117
app/binance.py
117
app/binance.py
|
|
@ -8,7 +8,25 @@ import pandas as pd
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
BINANCE_FUTURES_KLINES_URL = "https://fapi.binance.com/fapi/v1/klines"
|
BINANCE_FUTURES_KLINES_URL = "https://fapi.binance.com/fapi/v1/klines"
|
||||||
|
BINANCE_EXCHANGE_INFO_URL = "https://fapi.binance.com/fapi/v1/exchangeInfo"
|
||||||
DEFAULT_LIMIT = 90
|
DEFAULT_LIMIT = 90
|
||||||
|
# 6h charts use 1/3 of the default window so price action looks closer.
|
||||||
|
CHART_LIMIT_BY_INTERVAL: dict[str, int] = {
|
||||||
|
"15m": 135,
|
||||||
|
"6h": 75,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def chart_kline_limit(interval: str) -> int:
|
||||||
|
return CHART_LIMIT_BY_INTERVAL.get(interval, DEFAULT_LIMIT)
|
||||||
|
|
||||||
|
|
||||||
|
def chart_right_pad(interval: str) -> int:
|
||||||
|
"""Empty candles to the right; scale with the visible window."""
|
||||||
|
if interval == "6h":
|
||||||
|
return 12
|
||||||
|
return 15
|
||||||
|
|
||||||
|
|
||||||
# TradingView-style timeframe → Binance Futures interval
|
# TradingView-style timeframe → Binance Futures interval
|
||||||
TIMEFRAME_MAP: dict[str, str] = {
|
TIMEFRAME_MAP: dict[str, str] = {
|
||||||
|
|
@ -95,6 +113,45 @@ def to_binance_interval(visual_timeframe: str) -> str:
|
||||||
return interval
|
return interval
|
||||||
|
|
||||||
|
|
||||||
|
_INTERVAL_DELTA: dict[str, pd.Timedelta] = {
|
||||||
|
"1m": pd.Timedelta(minutes=1),
|
||||||
|
"3m": pd.Timedelta(minutes=3),
|
||||||
|
"5m": pd.Timedelta(minutes=5),
|
||||||
|
"15m": pd.Timedelta(minutes=15),
|
||||||
|
"30m": pd.Timedelta(minutes=30),
|
||||||
|
"1h": pd.Timedelta(hours=1),
|
||||||
|
"2h": pd.Timedelta(hours=2),
|
||||||
|
"4h": pd.Timedelta(hours=4),
|
||||||
|
"6h": pd.Timedelta(hours=6),
|
||||||
|
"8h": pd.Timedelta(hours=8),
|
||||||
|
"12h": pd.Timedelta(hours=12),
|
||||||
|
"1d": pd.Timedelta(days=1),
|
||||||
|
"1w": pd.Timedelta(weeks=1),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def interval_timedelta(interval: str) -> pd.Timedelta:
|
||||||
|
key = interval if interval in _INTERVAL_DELTA else to_binance_interval(interval)
|
||||||
|
delta = _INTERVAL_DELTA.get(key)
|
||||||
|
if delta is None:
|
||||||
|
raise ValueError(f"Unsupported interval: {interval!r}")
|
||||||
|
return delta
|
||||||
|
|
||||||
|
|
||||||
|
def drop_forming_candles(df: pd.DataFrame, interval: str) -> pd.DataFrame:
|
||||||
|
"""Drop the in-progress candle (open + interval > now)."""
|
||||||
|
if df.empty:
|
||||||
|
return df
|
||||||
|
delta = interval_timedelta(interval)
|
||||||
|
now = pd.Timestamp.now(tz="UTC")
|
||||||
|
idx = df.index
|
||||||
|
if idx.tz is None:
|
||||||
|
idx = idx.tz_localize("UTC")
|
||||||
|
else:
|
||||||
|
idx = idx.tz_convert("UTC")
|
||||||
|
return df.loc[idx + delta <= now]
|
||||||
|
|
||||||
|
|
||||||
async def fetch_klines(
|
async def fetch_klines(
|
||||||
symbol: str,
|
symbol: str,
|
||||||
interval: str,
|
interval: str,
|
||||||
|
|
@ -102,12 +159,16 @@ async def fetch_klines(
|
||||||
limit: int = DEFAULT_LIMIT,
|
limit: int = DEFAULT_LIMIT,
|
||||||
end_ms: int | None = None,
|
end_ms: int | None = None,
|
||||||
timeout: float = 15.0,
|
timeout: float = 15.0,
|
||||||
|
closed_only: bool = False,
|
||||||
|
client: httpx.AsyncClient | None = None,
|
||||||
) -> pd.DataFrame:
|
) -> pd.DataFrame:
|
||||||
"""Fetch OHLCV klines from Binance USDT-M Futures.
|
"""Fetch OHLCV klines from Binance USDT-M Futures.
|
||||||
|
|
||||||
If ``end_ms`` is set, returns candles ending at/before that UTC epoch millis
|
If ``end_ms`` is set, returns candles ending at/before that UTC epoch millis
|
||||||
(useful for historical / as-of charts).
|
(useful for historical / as-of charts).
|
||||||
"""
|
"""
|
||||||
|
if limit > 1500:
|
||||||
|
raise ValueError("Binance klines limit is 1500")
|
||||||
params: dict[str, str | int] = {
|
params: dict[str, str | int] = {
|
||||||
"symbol": symbol,
|
"symbol": symbol,
|
||||||
"interval": interval,
|
"interval": interval,
|
||||||
|
|
@ -115,10 +176,16 @@ async def fetch_klines(
|
||||||
}
|
}
|
||||||
if end_ms is not None:
|
if end_ms is not None:
|
||||||
params["endTime"] = end_ms
|
params["endTime"] = end_ms
|
||||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
|
||||||
response = await client.get(BINANCE_FUTURES_KLINES_URL, params=params)
|
http = client or httpx.AsyncClient(timeout=timeout)
|
||||||
|
own_client = client is None
|
||||||
|
try:
|
||||||
|
response = await http.get(BINANCE_FUTURES_KLINES_URL, params=params)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
raw = response.json()
|
raw = response.json()
|
||||||
|
finally:
|
||||||
|
if own_client:
|
||||||
|
await http.aclose()
|
||||||
|
|
||||||
if not raw:
|
if not raw:
|
||||||
raise ValueError(f"Empty klines for {symbol} {interval}")
|
raise ValueError(f"Empty klines for {symbol} {interval}")
|
||||||
|
|
@ -146,6 +213,52 @@ async def fetch_klines(
|
||||||
df = df.set_index("Date")[["open", "high", "low", "close", "volume"]]
|
df = df.set_index("Date")[["open", "high", "low", "close", "volume"]]
|
||||||
df.columns = ["Open", "High", "Low", "Close", "Volume"]
|
df.columns = ["Open", "High", "Low", "Close", "Volume"]
|
||||||
df = df.dropna()
|
df = df.dropna()
|
||||||
|
if closed_only:
|
||||||
|
df = drop_forming_candles(df, interval)
|
||||||
|
return df
|
||||||
if df.empty:
|
if df.empty:
|
||||||
raise ValueError(f"No valid OHLCV rows for {symbol} {interval}")
|
raise ValueError(f"No valid OHLCV rows for {symbol} {interval}")
|
||||||
return df
|
return df
|
||||||
|
|
||||||
|
|
||||||
|
_TICK_SIZE: dict[str, float] = {}
|
||||||
|
|
||||||
|
|
||||||
|
async def load_tick_sizes(
|
||||||
|
client: httpx.AsyncClient | None = None,
|
||||||
|
*,
|
||||||
|
timeout: float = 20.0,
|
||||||
|
) -> None:
|
||||||
|
"""Cache Binance USDT-M PRICE_FILTER.tickSize per symbol (Pine mintick)."""
|
||||||
|
http = client or httpx.AsyncClient(timeout=timeout)
|
||||||
|
own = client is None
|
||||||
|
try:
|
||||||
|
response = await http.get(BINANCE_EXCHANGE_INFO_URL)
|
||||||
|
response.raise_for_status()
|
||||||
|
payload = response.json()
|
||||||
|
finally:
|
||||||
|
if own:
|
||||||
|
await http.aclose()
|
||||||
|
|
||||||
|
ticks: dict[str, float] = {}
|
||||||
|
for item in payload.get("symbols") or []:
|
||||||
|
name = str(item.get("symbol") or "")
|
||||||
|
if not name:
|
||||||
|
continue
|
||||||
|
for filt in item.get("filters") or []:
|
||||||
|
if filt.get("filterType") == "PRICE_FILTER":
|
||||||
|
raw = filt.get("tickSize")
|
||||||
|
if raw is None:
|
||||||
|
continue
|
||||||
|
tick = float(raw)
|
||||||
|
if tick > 0:
|
||||||
|
ticks[name] = tick
|
||||||
|
break
|
||||||
|
if ticks:
|
||||||
|
_TICK_SIZE.update(ticks)
|
||||||
|
logger.info("Loaded tick sizes for %s symbols", len(ticks))
|
||||||
|
|
||||||
|
|
||||||
|
def get_tick_size(symbol: str) -> float:
|
||||||
|
key = to_binance_symbol(symbol)
|
||||||
|
return _TICK_SIZE.get(key, 0.01)
|
||||||
|
|
|
||||||
156
app/chart.py
156
app/chart.py
|
|
@ -125,6 +125,91 @@ def _position_start_x(df: pd.DataFrame, signal_time: int | None) -> int:
|
||||||
return min(pos, last_x)
|
return min(pos, last_x)
|
||||||
|
|
||||||
|
|
||||||
|
def _draw_previous_trade(
|
||||||
|
ax,
|
||||||
|
df: pd.DataFrame,
|
||||||
|
*,
|
||||||
|
prev_entry: float,
|
||||||
|
prev_side: str,
|
||||||
|
prev_signal_time: int,
|
||||||
|
exit_price: float,
|
||||||
|
exit_x: int,
|
||||||
|
) -> None:
|
||||||
|
"""Entry → exit of the trade that this reversal closes."""
|
||||||
|
prev_x = _position_start_x(df, prev_signal_time)
|
||||||
|
if prev_x >= exit_x:
|
||||||
|
return
|
||||||
|
profitable = (
|
||||||
|
exit_price >= prev_entry if prev_side == "long" else exit_price <= prev_entry
|
||||||
|
)
|
||||||
|
color = COLORS["up"] if profitable else COLORS["down"]
|
||||||
|
y_low = min(prev_entry, exit_price)
|
||||||
|
y_high = max(prev_entry, exit_price)
|
||||||
|
height = y_high - y_low
|
||||||
|
if height <= 0:
|
||||||
|
height = abs(prev_entry) * 1e-6 or 1e-8
|
||||||
|
ax.add_patch(
|
||||||
|
Rectangle(
|
||||||
|
(prev_x, y_low),
|
||||||
|
exit_x - prev_x,
|
||||||
|
height,
|
||||||
|
facecolor=color,
|
||||||
|
edgecolor="none",
|
||||||
|
alpha=0.04,
|
||||||
|
zorder=1,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
closes = df["Close"].iloc[prev_x : exit_x + 1].astype(float)
|
||||||
|
xs = list(range(prev_x, prev_x + len(closes)))
|
||||||
|
ax.plot(
|
||||||
|
xs,
|
||||||
|
closes.to_numpy(),
|
||||||
|
color=color,
|
||||||
|
linewidth=0.9,
|
||||||
|
linestyle=(0, (3, 4)),
|
||||||
|
alpha=0.28,
|
||||||
|
zorder=5,
|
||||||
|
solid_capstyle="round",
|
||||||
|
)
|
||||||
|
ax.plot(
|
||||||
|
[prev_x, exit_x],
|
||||||
|
[prev_entry, exit_price],
|
||||||
|
color=color,
|
||||||
|
linewidth=1.05,
|
||||||
|
linestyle=(0, (4, 5)),
|
||||||
|
alpha=0.45,
|
||||||
|
zorder=6,
|
||||||
|
)
|
||||||
|
ax.scatter(
|
||||||
|
[prev_x],
|
||||||
|
[prev_entry],
|
||||||
|
s=22,
|
||||||
|
c=color,
|
||||||
|
marker="o",
|
||||||
|
zorder=7,
|
||||||
|
edgecolors="#ffffff",
|
||||||
|
linewidths=0.7,
|
||||||
|
)
|
||||||
|
ax.annotate(
|
||||||
|
"Prev",
|
||||||
|
xy=(prev_x, prev_entry),
|
||||||
|
xytext=(6, 8),
|
||||||
|
textcoords="offset points",
|
||||||
|
va="bottom",
|
||||||
|
ha="left",
|
||||||
|
fontsize=7,
|
||||||
|
color=color,
|
||||||
|
zorder=8,
|
||||||
|
bbox={
|
||||||
|
"boxstyle": "round,pad=0.2",
|
||||||
|
"facecolor": COLORS["label_bg"],
|
||||||
|
"edgecolor": color,
|
||||||
|
"linewidth": 0.6,
|
||||||
|
"alpha": 0.88,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def render_setup_chart(
|
def render_setup_chart(
|
||||||
df: pd.DataFrame,
|
df: pd.DataFrame,
|
||||||
*,
|
*,
|
||||||
|
|
@ -133,18 +218,23 @@ def render_setup_chart(
|
||||||
entry: str,
|
entry: str,
|
||||||
stop_loss: str,
|
stop_loss: str,
|
||||||
tp1: str,
|
tp1: str,
|
||||||
tp2: str,
|
|
||||||
tp3: str,
|
|
||||||
timeframe: str,
|
timeframe: str,
|
||||||
|
tp2: str | None = None,
|
||||||
|
tp3: str | None = None,
|
||||||
current_price: str | None = None,
|
current_price: str | None = None,
|
||||||
signal_time: int | None = None,
|
signal_time: int | None = None,
|
||||||
|
right_pad: int | None = None,
|
||||||
|
prev_entry: str | None = None,
|
||||||
|
prev_side: str | None = None,
|
||||||
|
prev_signal_time: int | None = None,
|
||||||
) -> bytes:
|
) -> bytes:
|
||||||
entry_p = _parse_price(entry)
|
entry_p = _parse_price(entry)
|
||||||
sl_p = _parse_price(stop_loss)
|
sl_p = _parse_price(stop_loss)
|
||||||
tp1_p = _parse_price(tp1)
|
tp1_p = _parse_price(tp1)
|
||||||
tp2_p = _parse_price(tp2)
|
tp2_p = _parse_price(tp2) if tp2 else None
|
||||||
tp3_p = _parse_price(tp3)
|
tp3_p = _parse_price(tp3) if tp3 else None
|
||||||
current_p = _parse_price(current_price) if current_price is not None else None
|
current_p = _parse_price(current_price) if current_price is not None else None
|
||||||
|
prev_entry_p = _parse_price(prev_entry) if prev_entry else None
|
||||||
|
|
||||||
is_long = action == "long"
|
is_long = action == "long"
|
||||||
reward_rgb = COLORS["reward_fill"]
|
reward_rgb = COLORS["reward_fill"]
|
||||||
|
|
@ -154,13 +244,20 @@ def render_setup_chart(
|
||||||
# Position tool starts at seq==1 candle; "now" is the last real candle
|
# Position tool starts at seq==1 candle; "now" is the last real candle
|
||||||
now_x = len(df) - 1
|
now_x = len(df) - 1
|
||||||
entry_x = _position_start_x(df, signal_time)
|
entry_x = _position_start_x(df, signal_time)
|
||||||
plot_df = _pad_right(df, RIGHT_PAD_CANDLES)
|
plot_df = _pad_right(df, RIGHT_PAD_CANDLES if right_pad is None else right_pad)
|
||||||
|
|
||||||
level_prices = [entry_p, sl_p, tp1_p, tp2_p, tp3_p]
|
level_prices = [entry_p, sl_p, tp1_p]
|
||||||
|
if tp2_p is not None:
|
||||||
|
level_prices.append(tp2_p)
|
||||||
|
if tp3_p is not None:
|
||||||
|
level_prices.append(tp3_p)
|
||||||
if current_p is not None:
|
if current_p is not None:
|
||||||
level_prices.append(current_p)
|
level_prices.append(current_p)
|
||||||
|
if prev_entry_p is not None:
|
||||||
|
level_prices.append(prev_entry_p)
|
||||||
y_min = min(float(df["Low"].min()), *level_prices)
|
y_min = min(float(df["Low"].min()), *level_prices)
|
||||||
y_max = max(float(df["High"].max()), *level_prices)
|
y_max = max(float(df["High"].max()), *level_prices)
|
||||||
|
hold_remainder = tp2_p is None and tp3_p is None
|
||||||
price_pad = (y_max - y_min) * 0.06 or y_max * 0.002
|
price_pad = (y_max - y_min) * 0.06 or y_max * 0.002
|
||||||
|
|
||||||
vol_scaled, vol_colors = _volume_overlay(plot_df, y_low=y_min, y_high=y_max)
|
vol_scaled, vol_colors = _volume_overlay(plot_df, y_low=y_min, y_high=y_max)
|
||||||
|
|
@ -227,18 +324,22 @@ def render_setup_chart(
|
||||||
# Room for volume bars under candles
|
# Room for volume bars under candles
|
||||||
vol_floor = float(vol_scaled.dropna().min()) if vol_scaled.notna().any() else y_min
|
vol_floor = float(vol_scaled.dropna().min()) if vol_scaled.notna().any() else y_min
|
||||||
ax.set_ylim(min(y_min - price_pad, vol_floor) - price_pad * 0.3, y_max + price_pad)
|
ax.set_ylim(min(y_min - price_pad, vol_floor) - price_pad * 0.3, y_max + price_pad)
|
||||||
|
ylim_lo, ylim_hi = ax.get_ylim()
|
||||||
|
remainder_end = (ylim_hi if is_long else ylim_lo) if hold_remainder else None
|
||||||
|
|
||||||
x_right = ax.get_xlim()[1]
|
x_right = ax.get_xlim()[1]
|
||||||
zone_width = x_right - entry_x
|
zone_width = x_right - entry_x
|
||||||
|
|
||||||
# Levels + zones start at the entry (last real) candle, not full chart width
|
# Levels + zones start at the entry (last real) candle, not full chart width
|
||||||
level_specs = [
|
level_specs: list[tuple[float, str, str, float]] = [
|
||||||
(entry_p, COLORS["entry"], "--", 1.2),
|
(entry_p, COLORS["entry"], "--", 1.2),
|
||||||
(sl_p, COLORS["sl"], "-", 1.2),
|
(sl_p, COLORS["sl"], "-", 1.2),
|
||||||
(tp1_p, COLORS["tp1"], ":", 1.0),
|
(tp1_p, COLORS["tp1"], ":", 1.0),
|
||||||
(tp2_p, COLORS["tp2"], ":", 1.0),
|
|
||||||
(tp3_p, COLORS["tp3"], ":", 1.0),
|
|
||||||
]
|
]
|
||||||
|
if tp2_p is not None:
|
||||||
|
level_specs.append((tp2_p, COLORS["tp2"], ":", 1.0))
|
||||||
|
if tp3_p is not None:
|
||||||
|
level_specs.append((tp3_p, COLORS["tp3"], ":", 1.0))
|
||||||
for price, color, ls, lw in level_specs:
|
for price, color, ls, lw in level_specs:
|
||||||
ax.hlines(
|
ax.hlines(
|
||||||
price,
|
price,
|
||||||
|
|
@ -264,12 +365,17 @@ def render_setup_chart(
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
# Three reward bands with decreasing opacity toward farther TPs
|
reward_bands: list[tuple[float, float, float]] = [
|
||||||
reward_bands = (
|
|
||||||
(entry_p, tp1_p, REWARD_ALPHAS[0]),
|
(entry_p, tp1_p, REWARD_ALPHAS[0]),
|
||||||
(tp1_p, tp2_p, REWARD_ALPHAS[1]),
|
]
|
||||||
(tp2_p, tp3_p, REWARD_ALPHAS[2]),
|
if tp2_p is not None:
|
||||||
)
|
reward_bands.append((tp1_p, tp2_p, REWARD_ALPHAS[1]))
|
||||||
|
if tp2_p is not None and tp3_p is not None:
|
||||||
|
reward_bands.append((tp2_p, tp3_p, REWARD_ALPHAS[2]))
|
||||||
|
elif tp3_p is not None:
|
||||||
|
reward_bands.append((tp1_p, tp3_p, REWARD_ALPHAS[2]))
|
||||||
|
elif remainder_end is not None:
|
||||||
|
reward_bands.append((tp1_p, remainder_end, REWARD_ALPHAS[1]))
|
||||||
for price_a, price_b, alpha in reward_bands:
|
for price_a, price_b, alpha in reward_bands:
|
||||||
band_low = min(price_a, price_b)
|
band_low = min(price_a, price_b)
|
||||||
band_high = max(price_a, price_b)
|
band_high = max(price_a, price_b)
|
||||||
|
|
@ -295,6 +401,22 @@ def render_setup_chart(
|
||||||
linewidths=0.7,
|
linewidths=0.7,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (
|
||||||
|
prev_entry_p is not None
|
||||||
|
and prev_side is not None
|
||||||
|
and prev_signal_time is not None
|
||||||
|
and prev_signal_time > 0
|
||||||
|
):
|
||||||
|
_draw_previous_trade(
|
||||||
|
ax,
|
||||||
|
df,
|
||||||
|
prev_entry=prev_entry_p,
|
||||||
|
prev_side=prev_side,
|
||||||
|
prev_signal_time=prev_signal_time,
|
||||||
|
exit_price=entry_p,
|
||||||
|
exit_x=entry_x,
|
||||||
|
)
|
||||||
|
|
||||||
if current_p is not None:
|
if current_p is not None:
|
||||||
ax.hlines(
|
ax.hlines(
|
||||||
current_p,
|
current_p,
|
||||||
|
|
@ -321,9 +443,11 @@ def render_setup_chart(
|
||||||
(entry_p, f"Entry {entry}", COLORS["entry"]),
|
(entry_p, f"Entry {entry}", COLORS["entry"]),
|
||||||
(sl_p, f"SL {stop_loss}", COLORS["sl"]),
|
(sl_p, f"SL {stop_loss}", COLORS["sl"]),
|
||||||
(tp1_p, f"TP1 {tp1}", COLORS["tp1"]),
|
(tp1_p, f"TP1 {tp1}", COLORS["tp1"]),
|
||||||
(tp2_p, f"TP2 {tp2}", COLORS["tp2"]),
|
|
||||||
(tp3_p, f"TP3 {tp3}", COLORS["tp3"]),
|
|
||||||
]
|
]
|
||||||
|
if tp2_p is not None and tp2 is not None:
|
||||||
|
labels.append((tp2_p, f"TP2 {tp2}", COLORS["tp2"]))
|
||||||
|
if tp3_p is not None and tp3 is not None:
|
||||||
|
labels.append((tp3_p, f"TP3 {tp3}", COLORS["tp3"]))
|
||||||
if current_p is not None and current_price is not None:
|
if current_p is not None and current_price is not None:
|
||||||
labels.append((current_p, f"Price {current_price}", COLORS["price"]))
|
labels.append((current_p, f"Price {current_price}", COLORS["price"]))
|
||||||
for price, text, color in labels:
|
for price, text, color in labels:
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,31 @@ class Settings(BaseSettings):
|
||||||
host: str = "0.0.0.0"
|
host: str = "0.0.0.0"
|
||||||
port: int = 8000
|
port: int = 8000
|
||||||
|
|
||||||
|
scanner_enabled: bool = True
|
||||||
|
scanner_poll_seconds: float = 20.0
|
||||||
|
watchlist_path: str = "watchlist.yaml"
|
||||||
|
scanner_state_path: str = "data/scanner.db"
|
||||||
|
risk_usd: float = 40.0
|
||||||
|
|
||||||
|
geryon_webhook_url: str = ""
|
||||||
|
geryon_account_id: str = "admin1"
|
||||||
|
geryon_account_id_ltf: str = ""
|
||||||
|
geryon_account_id_fvg: str = ""
|
||||||
|
geryon_order_type: str = "bbo"
|
||||||
|
geryon_secret: str = ""
|
||||||
|
geryon_secret_ltf: str = ""
|
||||||
|
geryon_secret_fvg: str = ""
|
||||||
|
|
||||||
|
def geryon_account_id_for(self, strategy_id: str) -> str:
|
||||||
|
if strategy_id == "ltf":
|
||||||
|
return self.geryon_account_id_ltf or self.geryon_account_id
|
||||||
|
return self.geryon_account_id_fvg or self.geryon_account_id
|
||||||
|
|
||||||
|
def geryon_secret_for(self, strategy_id: str) -> str:
|
||||||
|
if strategy_id == "ltf":
|
||||||
|
return self.geryon_secret_ltf or self.geryon_secret
|
||||||
|
return self.geryon_secret_fvg or self.geryon_secret
|
||||||
|
|
||||||
|
|
||||||
@lru_cache
|
@lru_cache
|
||||||
def get_settings() -> Settings:
|
def get_settings() -> Settings:
|
||||||
|
|
|
||||||
|
|
@ -101,13 +101,18 @@ def format_caption(signal: SignalPayload) -> str:
|
||||||
signal.stop_loss_price,
|
signal.stop_loss_price,
|
||||||
is_long=is_long,
|
is_long=is_long,
|
||||||
)
|
)
|
||||||
return (
|
text = (
|
||||||
f"<b>{signal.ticker}</b> {label}\n"
|
f"<b>{signal.ticker}</b> {label}\n"
|
||||||
f"\n"
|
f"\n"
|
||||||
f"Entry price: {entry}\n"
|
f"Entry price: {entry}\n"
|
||||||
f"Price: {price}\n"
|
f"Price: {price}\n"
|
||||||
f"Current profit: {profit}"
|
f"Current profit: {profit}"
|
||||||
)
|
)
|
||||||
|
if signal.is_reversal and signal.realized_pnl_pct is not None:
|
||||||
|
text += (
|
||||||
|
f"\n\n<i>reversal, realized PnL {signal.realized_pnl_pct:+.2f}%</i>"
|
||||||
|
)
|
||||||
|
return text
|
||||||
|
|
||||||
price = format_price(signal.entry_price)
|
price = format_price(signal.entry_price)
|
||||||
sl = format_price(signal.stop_loss_price)
|
sl = format_price(signal.stop_loss_price)
|
||||||
|
|
@ -115,16 +120,30 @@ def format_caption(signal: SignalPayload) -> str:
|
||||||
signal.entry_price, signal.stop_loss_price, is_long=is_long
|
signal.entry_price, signal.stop_loss_price, is_long=is_long
|
||||||
)
|
)
|
||||||
tp1 = format_price(signal.take_profit_1_price)
|
tp1 = format_price(signal.take_profit_1_price)
|
||||||
tp2 = format_price(signal.take_profit_2_price)
|
hold_remainder = not signal.take_profit_2_price and not signal.take_profit_3_price
|
||||||
tp3 = format_price(signal.take_profit_3_price)
|
lines = [
|
||||||
|
f"<b>{signal.ticker}</b> {label}\n",
|
||||||
return (
|
f"Price: {price}",
|
||||||
f"<b>{signal.ticker}</b> {label}\n"
|
f"SL: {sl} {sl_pct}",
|
||||||
f"\n"
|
"",
|
||||||
f"Price: {price}\n"
|
f"TP1+BE: {tp1}" if hold_remainder else f"TP1: {tp1}",
|
||||||
f"SL: {sl} {sl_pct}\n"
|
]
|
||||||
f"\n"
|
if signal.take_profit_2_price:
|
||||||
f"TP1: {tp1}\n"
|
lines.append(f"TP2: {format_price(signal.take_profit_2_price)}")
|
||||||
f"TP2: {tp2}\n"
|
if signal.take_profit_3_price:
|
||||||
f"TP3: {tp3}"
|
lines.append(f"TP3: {format_price(signal.take_profit_3_price)}")
|
||||||
)
|
if hold_remainder:
|
||||||
|
lines.extend(
|
||||||
|
[
|
||||||
|
"",
|
||||||
|
"Fix 30%, hold remainder until reversal signal.",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
if signal.is_reversal and signal.realized_pnl_pct is not None:
|
||||||
|
lines.extend(
|
||||||
|
[
|
||||||
|
"",
|
||||||
|
f"<i>reversal, realized PnL {signal.realized_pnl_pct:+.2f}%</i>",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
|
||||||
81
app/heryon.py
Normal file
81
app/heryon.py
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from app.binance import get_tick_size
|
||||||
|
from app.config import Settings
|
||||||
|
from app.indicators.common import IndicatorSignal, calc_size, format_px, round_to_mintick
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
LTF_FIX = {"tp1_fix": "30%", "tp2_fix": "30%", "tp3_fix": "40%"}
|
||||||
|
FVG_FIX = {"tp1_fix": "30%"}
|
||||||
|
|
||||||
|
|
||||||
|
def to_tv_perp_ticker(symbol: str) -> str:
|
||||||
|
base = symbol.strip().upper()
|
||||||
|
if base.endswith(".P"):
|
||||||
|
return base
|
||||||
|
return f"{base}.P"
|
||||||
|
|
||||||
|
|
||||||
|
def heryon_nonce(ticker: str, side: str, bar_open_ts: int) -> str:
|
||||||
|
limit_side = "long" if side == "long" else "short"
|
||||||
|
return f"{ticker}-limit-{limit_side}-{bar_open_ts}"
|
||||||
|
|
||||||
|
|
||||||
|
def build_heryon_payload(
|
||||||
|
signal: IndicatorSignal,
|
||||||
|
*,
|
||||||
|
symbol: str,
|
||||||
|
settings: Settings,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
ticker = to_tv_perp_ticker(symbol)
|
||||||
|
tick = get_tick_size(symbol)
|
||||||
|
action = "buy" if signal.side == "long" else "sell"
|
||||||
|
fixes = LTF_FIX if signal.strategy_id == "ltf" else FVG_FIX
|
||||||
|
entry = round_to_mintick(signal.entry, tick)
|
||||||
|
sl = round_to_mintick(signal.sl, tick)
|
||||||
|
size = calc_size(entry, sl, float(settings.risk_usd))
|
||||||
|
size_str = "" if size != size else str(int(round(size)))
|
||||||
|
payload: dict[str, Any] = {
|
||||||
|
"action": action,
|
||||||
|
"ticker": ticker,
|
||||||
|
"account_id": settings.geryon_account_id_for(signal.strategy_id),
|
||||||
|
"order_type": settings.geryon_order_type,
|
||||||
|
"position_size_usd": size_str,
|
||||||
|
"stop_loss": format_px(signal.sl, tick),
|
||||||
|
"tp1": format_px(signal.tp1, tick),
|
||||||
|
"tp1_fix": fixes.get("tp1_fix", ""),
|
||||||
|
"sl_to_bk": "tp1",
|
||||||
|
"nonce": heryon_nonce(ticker, signal.side, signal.bar_open_ts),
|
||||||
|
"secret": settings.geryon_secret_for(signal.strategy_id),
|
||||||
|
}
|
||||||
|
if signal.strategy_id == "ltf":
|
||||||
|
payload["tp2"] = format_px(signal.tp2, tick)
|
||||||
|
payload["tp2_fix"] = fixes.get("tp2_fix", "")
|
||||||
|
payload["tp3"] = format_px(signal.tp3, tick)
|
||||||
|
payload["tp3_fix"] = fixes.get("tp3_fix", "")
|
||||||
|
return {key: value for key, value in payload.items() if value != ""}
|
||||||
|
|
||||||
|
|
||||||
|
async def send_heryon(settings: Settings, payload: dict[str, Any]) -> None:
|
||||||
|
url = (settings.geryon_webhook_url or "").strip()
|
||||||
|
if not url:
|
||||||
|
logger.info("Heryon skipped (GERYON_WEBHOOK_URL empty): %s", payload.get("nonce"))
|
||||||
|
return
|
||||||
|
async with httpx.AsyncClient(timeout=20.0) as client:
|
||||||
|
response = await client.post(url, json=payload)
|
||||||
|
if response.status_code >= 400:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Heryon webhook {response.status_code}: {response.text[:500]}"
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"Heryon accepted nonce=%s ticker=%s action=%s",
|
||||||
|
payload.get("nonce"),
|
||||||
|
payload.get("ticker"),
|
||||||
|
payload.get("action"),
|
||||||
|
)
|
||||||
11
app/indicators/__init__.py
Normal file
11
app/indicators/__init__.py
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
from app.indicators.common import IndicatorSignal, calc_size, format_px
|
||||||
|
from app.indicators.fvg import evaluate_fvg
|
||||||
|
from app.indicators.ltf import evaluate_ltf
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"IndicatorSignal",
|
||||||
|
"calc_size",
|
||||||
|
"evaluate_fvg",
|
||||||
|
"evaluate_ltf",
|
||||||
|
"format_px",
|
||||||
|
]
|
||||||
236
app/indicators/common.py
Normal file
236
app/indicators/common.py
Normal file
|
|
@ -0,0 +1,236 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from decimal import Decimal, ROUND_HALF_UP
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
StrategyId = Literal["ltf", "fvg"]
|
||||||
|
Side = Literal["long", "short"]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class IndicatorSignal:
|
||||||
|
strategy_id: StrategyId
|
||||||
|
side: Side
|
||||||
|
entry: float
|
||||||
|
close: float
|
||||||
|
sl: float
|
||||||
|
tp1: float
|
||||||
|
tp2: float | None
|
||||||
|
tp3: float | None
|
||||||
|
size_usd: float
|
||||||
|
bar_open_ts: int
|
||||||
|
visual_timeframe: str
|
||||||
|
|
||||||
|
|
||||||
|
def ohlc_frame(df: pd.DataFrame) -> pd.DataFrame:
|
||||||
|
"""Normalize OHLC frame to lowercase columns and UTC DatetimeIndex."""
|
||||||
|
out = df.copy()
|
||||||
|
out.columns = [str(c).lower() for c in out.columns]
|
||||||
|
required = {"open", "high", "low", "close"}
|
||||||
|
missing = required - set(out.columns)
|
||||||
|
if missing:
|
||||||
|
raise ValueError(f"OHLC frame missing columns: {sorted(missing)}")
|
||||||
|
if not isinstance(out.index, pd.DatetimeIndex):
|
||||||
|
raise ValueError("OHLC frame must be indexed by timestamp")
|
||||||
|
if out.index.tz is None:
|
||||||
|
out.index = out.index.tz_localize("UTC")
|
||||||
|
else:
|
||||||
|
out.index = out.index.tz_convert("UTC")
|
||||||
|
cols = ["open", "high", "low", "close"]
|
||||||
|
if "volume" in out.columns:
|
||||||
|
cols.append("volume")
|
||||||
|
return out[cols]
|
||||||
|
|
||||||
|
|
||||||
|
def epoch_ms(index: pd.DatetimeIndex) -> np.ndarray:
|
||||||
|
utc = index.tz_convert("UTC") if index.tz is not None else index.tz_localize("UTC")
|
||||||
|
return utc.asi8.astype(np.float64) / 1_000_000.0
|
||||||
|
|
||||||
|
|
||||||
|
def alma(
|
||||||
|
series: pd.Series, length: int = 500, offset: float = 0.85, sigma: float = 5.0
|
||||||
|
) -> pd.Series:
|
||||||
|
"""Arnaud Legoux Moving Average (Pine ta.alma compatible)."""
|
||||||
|
if length < 1:
|
||||||
|
raise ValueError("ALMA length must be >= 1")
|
||||||
|
m = offset * (length - 1)
|
||||||
|
s = length / sigma
|
||||||
|
idx = np.arange(length, dtype=float)
|
||||||
|
weights = np.exp(-((idx - m) ** 2) / (2 * s * s))
|
||||||
|
weights /= weights.sum()
|
||||||
|
|
||||||
|
values = series.to_numpy(dtype=float)
|
||||||
|
out = np.full(len(values), np.nan, dtype=float)
|
||||||
|
if len(values) < length:
|
||||||
|
return pd.Series(out, index=series.index)
|
||||||
|
|
||||||
|
valid = np.convolve(values, weights[::-1], mode="valid")
|
||||||
|
out[length - 1 :] = valid
|
||||||
|
nan_in_window = np.convolve(np.isnan(values).astype(float), np.ones(length), mode="valid") > 0
|
||||||
|
out[length - 1 :][nan_in_window] = np.nan
|
||||||
|
return pd.Series(out, index=series.index)
|
||||||
|
|
||||||
|
|
||||||
|
def rma(series: pd.Series, length: int) -> pd.Series:
|
||||||
|
"""Wilder's RMA (Pine ta.rma)."""
|
||||||
|
return series.ewm(alpha=1 / length, adjust=False, min_periods=length).mean()
|
||||||
|
|
||||||
|
|
||||||
|
def ha_rsi(
|
||||||
|
open_: pd.Series,
|
||||||
|
high: pd.Series,
|
||||||
|
low: pd.Series,
|
||||||
|
close: pd.Series,
|
||||||
|
length: int = 14,
|
||||||
|
) -> pd.Series:
|
||||||
|
"""RSI on Heikin-Ashi close (Pine f_ha_rsi)."""
|
||||||
|
ha_close = (open_ + high + low + close) / 4.0
|
||||||
|
delta = ha_close.diff()
|
||||||
|
up = rma(delta.clip(lower=0), length)
|
||||||
|
down = rma((-delta).clip(lower=0), length)
|
||||||
|
rs = up / down.replace(0, np.nan)
|
||||||
|
rsi = 100 - (100 / (1 + rs))
|
||||||
|
rsi = rsi.where(down != 0, 100.0)
|
||||||
|
rsi = rsi.where(up != 0, 0.0)
|
||||||
|
both_zero = (up == 0) & (down == 0)
|
||||||
|
return rsi.where(~both_zero, 100.0)
|
||||||
|
|
||||||
|
|
||||||
|
def true_range(high: pd.Series, low: pd.Series, close: pd.Series) -> pd.Series:
|
||||||
|
prev_close = close.shift(1)
|
||||||
|
return pd.concat(
|
||||||
|
[high - low, (high - prev_close).abs(), (low - prev_close).abs()],
|
||||||
|
axis=1,
|
||||||
|
).max(axis=1)
|
||||||
|
|
||||||
|
|
||||||
|
def atr(high: pd.Series, low: pd.Series, close: pd.Series, length: int) -> pd.Series:
|
||||||
|
"""Pine ta.atr(length)."""
|
||||||
|
return rma(true_range(high, low, close), length)
|
||||||
|
|
||||||
|
|
||||||
|
def compute_fractals(high: np.ndarray, low: np.ndarray, n: int = 5) -> tuple[np.ndarray, np.ndarray]:
|
||||||
|
"""Pine upFractal / downFractal flags on the confirmation bar (pivot at i - n)."""
|
||||||
|
size = len(high)
|
||||||
|
up = np.zeros(size, dtype=bool)
|
||||||
|
down = np.zeros(size, dtype=bool)
|
||||||
|
for c in range(n, size):
|
||||||
|
p = c - n
|
||||||
|
hp = high[p]
|
||||||
|
lp = low[p]
|
||||||
|
if np.isnan(hp) or np.isnan(lp):
|
||||||
|
continue
|
||||||
|
up_prefix1 = (p + 1 < size) and (high[p + 1] <= hp)
|
||||||
|
up_prefix2 = up_prefix1 and (p + 2 < size) and (high[p + 2] <= hp)
|
||||||
|
up_prefix3 = up_prefix2 and (p + 3 < size) and (high[p + 3] <= hp)
|
||||||
|
up_prefix4 = up_prefix3 and (p + 4 < size) and (high[p + 4] <= hp)
|
||||||
|
down_prefix1 = (p + 1 < size) and (low[p + 1] >= lp)
|
||||||
|
down_prefix2 = down_prefix1 and (p + 2 < size) and (low[p + 2] >= lp)
|
||||||
|
down_prefix3 = down_prefix2 and (p + 3 < size) and (low[p + 3] >= lp)
|
||||||
|
down_prefix4 = down_prefix3 and (p + 4 < size) and (low[p + 4] >= lp)
|
||||||
|
upflag_down = True
|
||||||
|
upflag0 = True
|
||||||
|
upflag1 = True
|
||||||
|
upflag2 = True
|
||||||
|
upflag3 = True
|
||||||
|
upflag4 = True
|
||||||
|
for i in range(1, n + 1):
|
||||||
|
if p - i < 0 or not (high[p - i] < hp):
|
||||||
|
upflag_down = False
|
||||||
|
if p + i >= size or not (high[p + i] < hp):
|
||||||
|
upflag0 = False
|
||||||
|
if p + i + 1 >= size or not (high[p + i + 1] < hp):
|
||||||
|
upflag1 = False
|
||||||
|
if p + i + 2 >= size or not (high[p + i + 2] < hp):
|
||||||
|
upflag2 = False
|
||||||
|
if p + i + 3 >= size or not (high[p + i + 3] < hp):
|
||||||
|
upflag3 = False
|
||||||
|
if p + i + 4 >= size or not (high[p + i + 4] < hp):
|
||||||
|
upflag4 = False
|
||||||
|
upflag1 = upflag1 and up_prefix1
|
||||||
|
upflag2 = upflag2 and up_prefix2
|
||||||
|
upflag3 = upflag3 and up_prefix3
|
||||||
|
upflag4 = upflag4 and up_prefix4
|
||||||
|
up[c] = upflag_down and (upflag0 or upflag1 or upflag2 or upflag3 or upflag4)
|
||||||
|
downflag_down = True
|
||||||
|
downflag0 = True
|
||||||
|
downflag1 = True
|
||||||
|
downflag2 = True
|
||||||
|
downflag3 = True
|
||||||
|
downflag4 = True
|
||||||
|
for i in range(1, n + 1):
|
||||||
|
if p - i < 0 or not (low[p - i] > lp):
|
||||||
|
downflag_down = False
|
||||||
|
if p + i >= size or not (low[p + i] > lp):
|
||||||
|
downflag0 = False
|
||||||
|
if p + i + 1 >= size or not (low[p + i + 1] > lp):
|
||||||
|
downflag1 = False
|
||||||
|
if p + i + 2 >= size or not (low[p + i + 2] > lp):
|
||||||
|
downflag2 = False
|
||||||
|
if p + i + 3 >= size or not (low[p + i + 3] > lp):
|
||||||
|
downflag3 = False
|
||||||
|
if p + i + 4 >= size or not (low[p + i + 4] > lp):
|
||||||
|
downflag4 = False
|
||||||
|
downflag1 = downflag1 and down_prefix1
|
||||||
|
downflag2 = downflag2 and down_prefix2
|
||||||
|
downflag3 = downflag3 and down_prefix3
|
||||||
|
downflag4 = downflag4 and down_prefix4
|
||||||
|
down[c] = downflag_down and (downflag0 or downflag1 or downflag2 or downflag3 or downflag4)
|
||||||
|
return up, down
|
||||||
|
|
||||||
|
|
||||||
|
def tick_decimals(tick: float) -> int:
|
||||||
|
exponent = Decimal(str(tick)).normalize().as_tuple().exponent
|
||||||
|
if isinstance(exponent, int):
|
||||||
|
return max(0, -exponent)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def round_to_mintick(price: float, tick: float) -> float:
|
||||||
|
"""Pine math.round_to_mintick (half-up to exchange tick)."""
|
||||||
|
step = Decimal(str(tick))
|
||||||
|
if step <= 0:
|
||||||
|
return price
|
||||||
|
quantized = (Decimal(str(price)) / step).quantize(Decimal("1"), rounding=ROUND_HALF_UP)
|
||||||
|
return float(quantized * step)
|
||||||
|
|
||||||
|
|
||||||
|
def realized_pnl_pct(prev_side: str, prev_entry: float, exit_price: float) -> float:
|
||||||
|
"""Signed percent from previous entry to exit; long/short from the closed side."""
|
||||||
|
if prev_entry == 0:
|
||||||
|
raise ValueError("entry price is zero")
|
||||||
|
if prev_side == "long":
|
||||||
|
return (exit_price - prev_entry) / prev_entry * 100
|
||||||
|
return (prev_entry - exit_price) / prev_entry * 100
|
||||||
|
|
||||||
|
|
||||||
|
def calc_size(entry: float, sl: float, risk_usd: float) -> float:
|
||||||
|
"""Pine calc_size: round(risk / abs(entry-sl)/entry)."""
|
||||||
|
if entry == 0 or np.isnan(entry) or np.isnan(sl):
|
||||||
|
return float("nan")
|
||||||
|
stop_pct = abs(entry - sl) / entry
|
||||||
|
if stop_pct <= 0:
|
||||||
|
return float("nan")
|
||||||
|
return float(round(risk_usd / stop_pct))
|
||||||
|
|
||||||
|
|
||||||
|
def format_px(value: float | None, tick: float | None = None) -> str:
|
||||||
|
if value is None:
|
||||||
|
return ""
|
||||||
|
number = float(value)
|
||||||
|
if np.isnan(number):
|
||||||
|
return ""
|
||||||
|
if tick is not None and tick > 0:
|
||||||
|
rounded = round_to_mintick(number, tick)
|
||||||
|
return f"{rounded:.{tick_decimals(tick)}f}"
|
||||||
|
if abs(number) >= 1000:
|
||||||
|
text = f"{number:.4f}"
|
||||||
|
elif abs(number) >= 1:
|
||||||
|
text = f"{number:.6f}"
|
||||||
|
else:
|
||||||
|
text = f"{number:.8f}"
|
||||||
|
return text.rstrip("0").rstrip(".")
|
||||||
364
app/indicators/fvg.py
Normal file
364
app/indicators/fvg.py
Normal file
|
|
@ -0,0 +1,364 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
from app.indicators.common import (
|
||||||
|
IndicatorSignal,
|
||||||
|
atr,
|
||||||
|
calc_size,
|
||||||
|
ohlc_frame,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class FvgParams:
|
||||||
|
engulf_cover: float = 0.85
|
||||||
|
breakout_n: int = 10
|
||||||
|
max_bars: int = 300
|
||||||
|
use_lock_until_sl: bool = True
|
||||||
|
use_energy: bool = False
|
||||||
|
ss_fast_len: int = 20
|
||||||
|
ss_slow_len: int = 50
|
||||||
|
energy_k: float = 0.15
|
||||||
|
atr_len: int = 14
|
||||||
|
sl_atr_n: float = 1.0
|
||||||
|
sl_live_r: float = 2.0
|
||||||
|
tp1_rr: float = 2.0
|
||||||
|
tp2_rr: float = 0.0
|
||||||
|
tp3_rr: float = 0.0
|
||||||
|
risk_usd: float = 40.0
|
||||||
|
visual_timeframe: str = "360"
|
||||||
|
|
||||||
|
|
||||||
|
def _calc_levels(
|
||||||
|
entry: float,
|
||||||
|
ind_sl: float,
|
||||||
|
is_long: bool,
|
||||||
|
sl_live_r: float,
|
||||||
|
tp1_rr: float,
|
||||||
|
tp2_rr: float,
|
||||||
|
tp3_rr: float,
|
||||||
|
) -> tuple[float, float | None, float | None, float | None]:
|
||||||
|
r = abs(entry - ind_sl)
|
||||||
|
live_sl = entry - r * sl_live_r if is_long else entry + r * sl_live_r
|
||||||
|
|
||||||
|
def _tp(rr: float) -> float | None:
|
||||||
|
if rr <= 0:
|
||||||
|
return None
|
||||||
|
return entry + r * rr if is_long else entry - r * rr
|
||||||
|
|
||||||
|
return live_sl, _tp(tp1_rr), _tp(tp2_rr), _tp(tp3_rr)
|
||||||
|
|
||||||
|
|
||||||
|
def evaluate_fvg(df_ohlc: pd.DataFrame, params: FvgParams | None = None) -> pd.DataFrame:
|
||||||
|
"""FVG + engulf/breakout matching live Pine (lock until SL, same-dir supersede)."""
|
||||||
|
p = params or FvgParams()
|
||||||
|
df = ohlc_frame(df_ohlc)
|
||||||
|
n = len(df)
|
||||||
|
if n == 0:
|
||||||
|
return df
|
||||||
|
|
||||||
|
open_ = df["open"].to_numpy(dtype=float)
|
||||||
|
high = df["high"].to_numpy(dtype=float)
|
||||||
|
low = df["low"].to_numpy(dtype=float)
|
||||||
|
close = df["close"].to_numpy(dtype=float)
|
||||||
|
|
||||||
|
atr_s = atr(df["high"], df["low"], df["close"], p.atr_len)
|
||||||
|
atr_ok = np.nan_to_num(atr_s.to_numpy(dtype=float), nan=0.0)
|
||||||
|
|
||||||
|
long_energy_ok = np.ones(n, dtype=bool)
|
||||||
|
short_energy_ok = np.ones(n, dtype=bool)
|
||||||
|
|
||||||
|
prior_high_n = (
|
||||||
|
df["high"].shift(1).rolling(p.breakout_n, min_periods=p.breakout_n).max().to_numpy(dtype=float)
|
||||||
|
)
|
||||||
|
prior_low_n = (
|
||||||
|
df["low"].shift(1).rolling(p.breakout_n, min_periods=p.breakout_n).min().to_numpy(dtype=float)
|
||||||
|
)
|
||||||
|
|
||||||
|
prev_body = np.empty(n, dtype=float)
|
||||||
|
prev_body[0] = np.nan
|
||||||
|
prev_body[1:] = np.abs(close[:-1] - open_[:-1])
|
||||||
|
prev_bearish = np.zeros(n, dtype=bool)
|
||||||
|
prev_bullish = np.zeros(n, dtype=bool)
|
||||||
|
prev_bearish[1:] = close[:-1] < open_[:-1]
|
||||||
|
prev_bullish[1:] = close[:-1] > open_[:-1]
|
||||||
|
bull_min_cover = np.empty(n, dtype=float)
|
||||||
|
bear_min_cover = np.empty(n, dtype=float)
|
||||||
|
bull_min_cover[0] = np.nan
|
||||||
|
bear_min_cover[0] = np.nan
|
||||||
|
bull_min_cover[1:] = close[:-1] + prev_body[1:] * p.engulf_cover
|
||||||
|
bear_min_cover[1:] = close[:-1] - prev_body[1:] * p.engulf_cover
|
||||||
|
|
||||||
|
bull_engulf = (close > open_) & prev_bearish & (close >= bull_min_cover)
|
||||||
|
bear_engulf = (close < open_) & prev_bullish & (close <= bear_min_cover)
|
||||||
|
bull_bo = close > prior_high_n
|
||||||
|
bear_bo = close < prior_low_n
|
||||||
|
|
||||||
|
sl_long_px = close - atr_ok * p.sl_atr_n
|
||||||
|
sl_short_px = close + atr_ok * p.sl_atr_n
|
||||||
|
|
||||||
|
accepted_long = np.zeros(n, dtype=bool)
|
||||||
|
accepted_short = np.zeros(n, dtype=bool)
|
||||||
|
entry_px = np.full(n, np.nan)
|
||||||
|
long_sl_out = np.full(n, np.nan)
|
||||||
|
short_sl_out = np.full(n, np.nan)
|
||||||
|
long_tp1 = np.full(n, np.nan)
|
||||||
|
long_tp2 = np.full(n, np.nan)
|
||||||
|
long_tp3 = np.full(n, np.nan)
|
||||||
|
short_tp1 = np.full(n, np.nan)
|
||||||
|
short_tp2 = np.full(n, np.nan)
|
||||||
|
short_tp3 = np.full(n, np.nan)
|
||||||
|
|
||||||
|
bots: list[float] = []
|
||||||
|
tops: list[float] = []
|
||||||
|
dirs: list[int] = []
|
||||||
|
created: list[int] = []
|
||||||
|
mid_lo: list[float] = []
|
||||||
|
mid_hi: list[float] = []
|
||||||
|
valid: list[bool] = []
|
||||||
|
|
||||||
|
long_locked = False
|
||||||
|
short_locked = False
|
||||||
|
lock_long_sl = np.nan
|
||||||
|
lock_short_sl = np.nan
|
||||||
|
lock_long_bar = -1
|
||||||
|
lock_short_bar = -1
|
||||||
|
pos_dir = 0
|
||||||
|
pos_sl = np.nan
|
||||||
|
pos_bar = -1
|
||||||
|
|
||||||
|
last_long_sl = np.nan
|
||||||
|
last_long_tp1 = np.nan
|
||||||
|
last_long_tp2 = np.nan
|
||||||
|
last_long_tp3 = np.nan
|
||||||
|
last_short_sl = np.nan
|
||||||
|
last_short_tp1 = np.nan
|
||||||
|
last_short_tp2 = np.nan
|
||||||
|
last_short_tp3 = np.nan
|
||||||
|
last_entry = np.nan
|
||||||
|
|
||||||
|
def _remove(idx: int) -> None:
|
||||||
|
del bots[idx], tops[idx], dirs[idx], created[idx], mid_lo[idx], mid_hi[idx], valid[idx]
|
||||||
|
|
||||||
|
def _add(direction: int, bot: float, top: float, mlo: float, mhi: float, bar: int) -> None:
|
||||||
|
for k in range(len(dirs)):
|
||||||
|
if dirs[k] == direction:
|
||||||
|
valid[k] = False
|
||||||
|
bots.append(bot)
|
||||||
|
tops.append(top)
|
||||||
|
dirs.append(direction)
|
||||||
|
created.append(bar)
|
||||||
|
mid_lo.append(mlo)
|
||||||
|
mid_hi.append(mhi)
|
||||||
|
valid.append(True)
|
||||||
|
|
||||||
|
for i in range(n):
|
||||||
|
j = len(bots) - 1
|
||||||
|
while j >= 0:
|
||||||
|
if i - created[j] > p.max_bars:
|
||||||
|
_remove(j)
|
||||||
|
j -= 1
|
||||||
|
|
||||||
|
for k in range(len(bots)):
|
||||||
|
if not valid[k]:
|
||||||
|
continue
|
||||||
|
broken = close[i] < mid_lo[k] if dirs[k] == 1 else close[i] > mid_hi[k]
|
||||||
|
if broken:
|
||||||
|
valid[k] = False
|
||||||
|
|
||||||
|
new_bull = i >= 2 and low[i] > high[i - 2]
|
||||||
|
new_bear = i >= 2 and high[i] < low[i - 2]
|
||||||
|
if new_bull:
|
||||||
|
_add(1, float(high[i - 2]), float(low[i]), float(low[i - 1]), float(high[i - 1]), i)
|
||||||
|
if new_bear:
|
||||||
|
_add(-1, float(high[i]), float(low[i - 2]), float(low[i - 1]), float(high[i - 1]), i)
|
||||||
|
if (new_bull or new_bear) and bots:
|
||||||
|
last = len(bots) - 1
|
||||||
|
broken_new = close[i] < mid_lo[last] if dirs[last] == 1 else close[i] > mid_hi[last]
|
||||||
|
if broken_new:
|
||||||
|
valid[last] = False
|
||||||
|
|
||||||
|
sl_long_hit = pos_dir == 1 and i > pos_bar and low[i] <= pos_sl
|
||||||
|
sl_short_hit = pos_dir == -1 and i > pos_bar and high[i] >= pos_sl
|
||||||
|
if sl_long_hit or sl_short_hit:
|
||||||
|
pos_dir = 0
|
||||||
|
pos_sl = np.nan
|
||||||
|
pos_bar = -1
|
||||||
|
if p.use_lock_until_sl:
|
||||||
|
if sl_long_hit or (long_locked and i > lock_long_bar and low[i] <= lock_long_sl):
|
||||||
|
long_locked = False
|
||||||
|
lock_long_sl = np.nan
|
||||||
|
lock_long_bar = -1
|
||||||
|
if sl_short_hit or (short_locked and i > lock_short_bar and high[i] >= lock_short_sl):
|
||||||
|
short_locked = False
|
||||||
|
lock_short_sl = np.nan
|
||||||
|
lock_short_bar = -1
|
||||||
|
|
||||||
|
cur_idx = -1
|
||||||
|
for k in range(len(bots) - 1, -1, -1):
|
||||||
|
if valid[k]:
|
||||||
|
cur_idx = k
|
||||||
|
break
|
||||||
|
|
||||||
|
sig_long = False
|
||||||
|
sig_short = False
|
||||||
|
if cur_idx >= 0:
|
||||||
|
cur_dir = dirs[cur_idx]
|
||||||
|
cur_bot = bots[cur_idx]
|
||||||
|
cur_top = tops[cur_idx]
|
||||||
|
opp_ok = True
|
||||||
|
prev = cur_idx - 1
|
||||||
|
while prev >= 0:
|
||||||
|
if dirs[prev] != cur_dir:
|
||||||
|
opp_ok = not valid[prev]
|
||||||
|
break
|
||||||
|
prev -= 1
|
||||||
|
|
||||||
|
if opp_ok:
|
||||||
|
long_free = (not p.use_lock_until_sl) or (not long_locked)
|
||||||
|
short_free = (not p.use_lock_until_sl) or (not short_locked)
|
||||||
|
long_ok = cur_dir == 1 and close[i] > cur_bot
|
||||||
|
short_ok = cur_dir == -1 and close[i] < cur_top
|
||||||
|
long_pat = long_ok and long_free and (bool(bull_engulf[i]) or bool(bull_bo[i]))
|
||||||
|
short_pat = short_ok and short_free and (bool(bear_engulf[i]) or bool(bear_bo[i]))
|
||||||
|
sig_long = long_pat and bool(long_energy_ok[i])
|
||||||
|
sig_short = short_pat and bool(short_energy_ok[i])
|
||||||
|
|
||||||
|
if sig_long:
|
||||||
|
entry = float(close[i])
|
||||||
|
ind_sl = float(sl_long_px[i])
|
||||||
|
if np.isnan(ind_sl) or ind_sl >= entry:
|
||||||
|
sig_long = False
|
||||||
|
else:
|
||||||
|
live_sl, t1, t2, t3 = _calc_levels(
|
||||||
|
entry, ind_sl, True, p.sl_live_r, p.tp1_rr, p.tp2_rr, p.tp3_rr
|
||||||
|
)
|
||||||
|
last_long_sl, last_long_tp1, last_long_tp2, last_long_tp3 = (
|
||||||
|
live_sl, t1, t2 if t2 is not None else np.nan, t3 if t3 is not None else np.nan
|
||||||
|
)
|
||||||
|
last_short_sl = last_short_tp1 = last_short_tp2 = last_short_tp3 = np.nan
|
||||||
|
last_entry = entry
|
||||||
|
pos_dir = 1
|
||||||
|
pos_sl = live_sl
|
||||||
|
pos_bar = i
|
||||||
|
if p.use_lock_until_sl:
|
||||||
|
long_locked = True
|
||||||
|
lock_long_sl = live_sl
|
||||||
|
lock_long_bar = i
|
||||||
|
short_locked = False
|
||||||
|
lock_short_sl = np.nan
|
||||||
|
lock_short_bar = -1
|
||||||
|
|
||||||
|
if sig_short:
|
||||||
|
entry = float(close[i])
|
||||||
|
ind_sl = float(sl_short_px[i])
|
||||||
|
if np.isnan(ind_sl) or ind_sl <= entry:
|
||||||
|
sig_short = False
|
||||||
|
else:
|
||||||
|
live_sl, t1, t2, t3 = _calc_levels(
|
||||||
|
entry, ind_sl, False, p.sl_live_r, p.tp1_rr, p.tp2_rr, p.tp3_rr
|
||||||
|
)
|
||||||
|
last_short_sl, last_short_tp1, last_short_tp2, last_short_tp3 = (
|
||||||
|
live_sl, t1, t2 if t2 is not None else np.nan, t3 if t3 is not None else np.nan
|
||||||
|
)
|
||||||
|
last_long_sl = last_long_tp1 = last_long_tp2 = last_long_tp3 = np.nan
|
||||||
|
last_entry = entry
|
||||||
|
pos_dir = -1
|
||||||
|
pos_sl = live_sl
|
||||||
|
pos_bar = i
|
||||||
|
if p.use_lock_until_sl:
|
||||||
|
short_locked = True
|
||||||
|
lock_short_sl = live_sl
|
||||||
|
lock_short_bar = i
|
||||||
|
long_locked = False
|
||||||
|
lock_long_sl = np.nan
|
||||||
|
lock_long_bar = -1
|
||||||
|
|
||||||
|
accepted_long[i] = sig_long
|
||||||
|
accepted_short[i] = sig_short
|
||||||
|
entry_px[i] = last_entry
|
||||||
|
long_sl_out[i] = last_long_sl
|
||||||
|
short_sl_out[i] = last_short_sl
|
||||||
|
long_tp1[i] = last_long_tp1
|
||||||
|
long_tp2[i] = last_long_tp2
|
||||||
|
long_tp3[i] = last_long_tp3
|
||||||
|
short_tp1[i] = last_short_tp1
|
||||||
|
short_tp2[i] = last_short_tp2
|
||||||
|
short_tp3[i] = last_short_tp3
|
||||||
|
|
||||||
|
df["atr"] = atr_s
|
||||||
|
df["buy_sig"] = accepted_long
|
||||||
|
df["sell_sig"] = accepted_short
|
||||||
|
df["entry_px"] = entry_px
|
||||||
|
df["long_sl"] = long_sl_out
|
||||||
|
df["short_sl"] = short_sl_out
|
||||||
|
df["long_tp1"] = long_tp1
|
||||||
|
df["long_tp2"] = long_tp2
|
||||||
|
df["long_tp3"] = long_tp3
|
||||||
|
df["short_tp1"] = short_tp1
|
||||||
|
df["short_tp2"] = short_tp2
|
||||||
|
df["short_tp3"] = short_tp3
|
||||||
|
return df
|
||||||
|
|
||||||
|
|
||||||
|
def last_fvg_signal(df: pd.DataFrame, params: FvgParams | None = None) -> IndicatorSignal | None:
|
||||||
|
if df.empty:
|
||||||
|
return None
|
||||||
|
p = params or FvgParams()
|
||||||
|
last = df.iloc[-1]
|
||||||
|
ts = int(df.index[-1].timestamp())
|
||||||
|
close = float(last["close"])
|
||||||
|
|
||||||
|
def _opt(value: object) -> float | None:
|
||||||
|
if value is None or (isinstance(value, float) and np.isnan(value)):
|
||||||
|
return None
|
||||||
|
number = float(value)
|
||||||
|
return None if np.isnan(number) else number
|
||||||
|
|
||||||
|
if bool(last["buy_sig"]):
|
||||||
|
sl = float(last["long_sl"])
|
||||||
|
tp1 = float(last["long_tp1"])
|
||||||
|
if np.isnan(sl) or np.isnan(tp1) or np.isnan(close):
|
||||||
|
return None
|
||||||
|
size = calc_size(close, sl, p.risk_usd)
|
||||||
|
if np.isnan(size):
|
||||||
|
return None
|
||||||
|
return IndicatorSignal(
|
||||||
|
strategy_id="fvg",
|
||||||
|
side="long",
|
||||||
|
entry=close,
|
||||||
|
close=close,
|
||||||
|
sl=sl,
|
||||||
|
tp1=tp1,
|
||||||
|
tp2=_opt(last["long_tp2"]),
|
||||||
|
tp3=_opt(last["long_tp3"]),
|
||||||
|
size_usd=size,
|
||||||
|
bar_open_ts=ts,
|
||||||
|
visual_timeframe=p.visual_timeframe,
|
||||||
|
)
|
||||||
|
if bool(last["sell_sig"]):
|
||||||
|
sl = float(last["short_sl"])
|
||||||
|
tp1 = float(last["short_tp1"])
|
||||||
|
if np.isnan(sl) or np.isnan(tp1) or np.isnan(close):
|
||||||
|
return None
|
||||||
|
size = calc_size(close, sl, p.risk_usd)
|
||||||
|
if np.isnan(size):
|
||||||
|
return None
|
||||||
|
return IndicatorSignal(
|
||||||
|
strategy_id="fvg",
|
||||||
|
side="short",
|
||||||
|
entry=close,
|
||||||
|
close=close,
|
||||||
|
sl=sl,
|
||||||
|
tp1=tp1,
|
||||||
|
tp2=_opt(last["short_tp2"]),
|
||||||
|
tp3=_opt(last["short_tp3"]),
|
||||||
|
size_usd=size,
|
||||||
|
bar_open_ts=ts,
|
||||||
|
visual_timeframe=p.visual_timeframe,
|
||||||
|
)
|
||||||
|
return None
|
||||||
384
app/indicators/ltf.py
Normal file
384
app/indicators/ltf.py
Normal file
|
|
@ -0,0 +1,384 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
from app.indicators.common import (
|
||||||
|
IndicatorSignal,
|
||||||
|
alma,
|
||||||
|
calc_size,
|
||||||
|
compute_fractals,
|
||||||
|
epoch_ms,
|
||||||
|
ha_rsi,
|
||||||
|
ohlc_frame,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class LtfParams:
|
||||||
|
fractal_n: int = 5
|
||||||
|
alma_length: int = 500
|
||||||
|
engulf_threshold: float = 0.9
|
||||||
|
rsi_6h_overbought: float = 75.0
|
||||||
|
rsi_6h_oversold: float = 20.0
|
||||||
|
tp1_rr: float = 1.75
|
||||||
|
tp2_rr: float = 2.0
|
||||||
|
tp3_rr: float = 4.0
|
||||||
|
risk_usd: float = 40.0
|
||||||
|
cooldown_ms: int = 360 * 60 * 1000
|
||||||
|
engulf_reset_ms: int = 12 * 60 * 60 * 1000
|
||||||
|
tf_minutes: int = 15
|
||||||
|
visual_timeframe: str = "15"
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_asof(df_15m: pd.DataFrame, df_6h: pd.DataFrame, columns: list[str]) -> pd.DataFrame:
|
||||||
|
left = pd.DataFrame({"ts": df_15m.index})
|
||||||
|
right = pd.DataFrame({"ts": df_6h.index})
|
||||||
|
for col in columns:
|
||||||
|
right[col] = df_6h[col].to_numpy()
|
||||||
|
left = left.sort_values("ts")
|
||||||
|
right = right.sort_values("ts")
|
||||||
|
merged = pd.merge_asof(left, right, on="ts", direction="backward")
|
||||||
|
merged = merged.set_index("ts")
|
||||||
|
return merged.reindex(df_15m.index)
|
||||||
|
|
||||||
|
|
||||||
|
def _compute_6h(
|
||||||
|
df_6h: pd.DataFrame,
|
||||||
|
*,
|
||||||
|
engulf_threshold: float,
|
||||||
|
forming: pd.Series | None,
|
||||||
|
) -> pd.DataFrame:
|
||||||
|
df = ohlc_frame(df_6h)
|
||||||
|
df["ha_rsi"] = ha_rsi(df["open"], df["high"], df["low"], df["close"], 14)
|
||||||
|
df["h6_close"] = df["close"]
|
||||||
|
df["h6_prev_close"] = df["close"].shift(1)
|
||||||
|
df["h6_open"] = df["open"]
|
||||||
|
df["h6_prev_open"] = df["open"].shift(1)
|
||||||
|
|
||||||
|
body2 = (df["h6_prev_open"] - df["h6_prev_close"]).abs()
|
||||||
|
bull_min = df["h6_prev_close"] + body2 * engulf_threshold
|
||||||
|
bear_min = df["h6_prev_close"] - body2 * engulf_threshold
|
||||||
|
bull_engulf = (
|
||||||
|
(df["h6_close"] > df["h6_open"])
|
||||||
|
& (df["h6_close"] >= bull_min)
|
||||||
|
& (df["h6_prev_close"] < df["h6_prev_open"])
|
||||||
|
)
|
||||||
|
bear_engulf = (
|
||||||
|
(df["h6_close"] < df["h6_open"])
|
||||||
|
& (df["h6_close"] <= bear_min)
|
||||||
|
& (df["h6_prev_close"] > df["h6_prev_open"])
|
||||||
|
)
|
||||||
|
|
||||||
|
n = len(df)
|
||||||
|
is_forming = np.zeros(n, dtype=bool)
|
||||||
|
if forming is not None:
|
||||||
|
is_forming = forming.reindex(df.index).fillna(False).to_numpy(dtype=bool)
|
||||||
|
|
||||||
|
state = np.empty(n, dtype=object)
|
||||||
|
state[:] = ""
|
||||||
|
engulf_time = np.full(n, np.nan)
|
||||||
|
cur_state = ""
|
||||||
|
cur_time = np.nan
|
||||||
|
ts_ms = epoch_ms(df.index)
|
||||||
|
bull_a = bull_engulf.fillna(False).to_numpy(dtype=bool)
|
||||||
|
bear_a = bear_engulf.fillna(False).to_numpy(dtype=bool)
|
||||||
|
|
||||||
|
for i in range(n):
|
||||||
|
ts = ts_ms[i]
|
||||||
|
if not np.isnan(cur_time) and (ts - cur_time) > 12 * 60 * 60 * 1000:
|
||||||
|
cur_state = ""
|
||||||
|
cur_time = np.nan
|
||||||
|
|
||||||
|
if is_forming[i]:
|
||||||
|
state[i] = cur_state
|
||||||
|
engulf_time[i] = cur_time
|
||||||
|
continue
|
||||||
|
|
||||||
|
if bull_a[i]:
|
||||||
|
cur_state = "bull"
|
||||||
|
cur_time = ts
|
||||||
|
elif bear_a[i]:
|
||||||
|
cur_state = "bear"
|
||||||
|
cur_time = ts
|
||||||
|
else:
|
||||||
|
cur_state = ""
|
||||||
|
cur_time = np.nan
|
||||||
|
|
||||||
|
state[i] = cur_state
|
||||||
|
engulf_time[i] = cur_time
|
||||||
|
|
||||||
|
df["engulf_state"] = state
|
||||||
|
df["engulf_time"] = engulf_time
|
||||||
|
return df
|
||||||
|
|
||||||
|
|
||||||
|
def attach_forming_6h(df_6h_closed: pd.DataFrame, df_15m: pd.DataFrame) -> pd.DataFrame:
|
||||||
|
"""Rebuild the in-progress 6h candle from closed 15m bars (Pine request.security)."""
|
||||||
|
closed = ohlc_frame(df_6h_closed)
|
||||||
|
ltf = ohlc_frame(df_15m)
|
||||||
|
if ltf.empty:
|
||||||
|
closed["_forming"] = False
|
||||||
|
return closed
|
||||||
|
|
||||||
|
period = ltf.index[-1].floor("6h")
|
||||||
|
window = ltf.loc[ltf.index >= period]
|
||||||
|
if window.empty:
|
||||||
|
closed["_forming"] = False
|
||||||
|
return closed
|
||||||
|
|
||||||
|
# 6h just closed and is already in the closed frame.
|
||||||
|
if not closed.empty and closed.index[-1] == period:
|
||||||
|
closed["_forming"] = False
|
||||||
|
return closed
|
||||||
|
|
||||||
|
row = pd.DataFrame(
|
||||||
|
{
|
||||||
|
"open": [float(window["open"].iloc[0])],
|
||||||
|
"high": [float(window["high"].max())],
|
||||||
|
"low": [float(window["low"].min())],
|
||||||
|
"close": [float(window["close"].iloc[-1])],
|
||||||
|
},
|
||||||
|
index=pd.DatetimeIndex([period], tz="UTC"),
|
||||||
|
)
|
||||||
|
if "volume" in window.columns:
|
||||||
|
row["volume"] = float(window["volume"].sum())
|
||||||
|
row["_forming"] = True
|
||||||
|
base = closed[closed.index < period].copy()
|
||||||
|
base["_forming"] = False
|
||||||
|
return pd.concat([base, row])
|
||||||
|
|
||||||
|
|
||||||
|
def evaluate_ltf(
|
||||||
|
df_15m: pd.DataFrame,
|
||||||
|
df_6h: pd.DataFrame,
|
||||||
|
params: LtfParams | None = None,
|
||||||
|
*,
|
||||||
|
forming_6h: pd.Series | None = None,
|
||||||
|
) -> pd.DataFrame:
|
||||||
|
"""Compute LTF columns. Last row is the latest closed 15m bar."""
|
||||||
|
p = params or LtfParams()
|
||||||
|
df = ohlc_frame(df_15m)
|
||||||
|
forming_flag = forming_6h
|
||||||
|
if forming_flag is None and "_forming" in df_6h.columns:
|
||||||
|
forming_flag = df_6h["_forming"].astype(bool)
|
||||||
|
h6 = _compute_6h(df_6h, engulf_threshold=p.engulf_threshold, forming=forming_flag)
|
||||||
|
if forming_flag is not None:
|
||||||
|
closed_mask = ~forming_flag.reindex(h6.index).fillna(False)
|
||||||
|
h6_closed = h6.loc[closed_mask]
|
||||||
|
else:
|
||||||
|
h6_closed = h6
|
||||||
|
|
||||||
|
n = int(p.fractal_n)
|
||||||
|
high = df["high"].to_numpy(dtype=float)
|
||||||
|
low = df["low"].to_numpy(dtype=float)
|
||||||
|
up_frac, down_frac = compute_fractals(high, low, n=n)
|
||||||
|
df["up_fractal"] = up_frac
|
||||||
|
df["down_fractal"] = down_frac
|
||||||
|
|
||||||
|
up_level = np.full(len(df), np.nan)
|
||||||
|
down_level = np.full(len(df), np.nan)
|
||||||
|
last_up = np.nan
|
||||||
|
last_down = np.nan
|
||||||
|
for i in range(len(df)):
|
||||||
|
if up_frac[i]:
|
||||||
|
last_up = high[i - n]
|
||||||
|
if down_frac[i]:
|
||||||
|
last_down = low[i - n]
|
||||||
|
up_level[i] = last_up
|
||||||
|
down_level[i] = last_down
|
||||||
|
df["up_fractal_level"] = up_level
|
||||||
|
df["down_fractal_level"] = down_level
|
||||||
|
|
||||||
|
prev_up = df["up_fractal_level"].shift(1)
|
||||||
|
prev_down = df["down_fractal_level"].shift(1)
|
||||||
|
df["buy_crossover"] = (
|
||||||
|
(df["close"] > prev_up) & (df["close"].shift(1) <= prev_up.shift(1)) & prev_up.notna()
|
||||||
|
)
|
||||||
|
df["sell_crossover"] = (
|
||||||
|
(df["close"] < prev_down) & (df["close"].shift(1) >= prev_down.shift(1)) & prev_down.notna()
|
||||||
|
)
|
||||||
|
|
||||||
|
bars_16h = max(1, int(round(1440 / p.tf_minutes)))
|
||||||
|
high_shift = df["high"].shift(1)
|
||||||
|
low_shift = df["low"].shift(1)
|
||||||
|
df["high_16h"] = high_shift.rolling(bars_16h, min_periods=bars_16h).max()
|
||||||
|
df["low_16h"] = low_shift.rolling(bars_16h, min_periods=bars_16h).min()
|
||||||
|
df["bull_breakout"] = df["high"] > df["high_16h"]
|
||||||
|
df["bear_breakout"] = df["low"] < df["low_16h"]
|
||||||
|
|
||||||
|
df["alma"] = alma(df["close"], length=p.alma_length, offset=0.85, sigma=5.0)
|
||||||
|
|
||||||
|
rsi_m = _merge_asof(df, h6, ["ha_rsi"])
|
||||||
|
closed_src = h6_closed if not h6_closed.empty else h6
|
||||||
|
closed_m = _merge_asof(
|
||||||
|
df, closed_src, ["engulf_state", "engulf_time", "h6_close", "h6_prev_close"]
|
||||||
|
)
|
||||||
|
df["rsi_6h"] = rsi_m["ha_rsi"]
|
||||||
|
df["h6_engulf_state"] = closed_m["engulf_state"].fillna("").astype(str)
|
||||||
|
df["h6_closed_close"] = closed_m["h6_close"]
|
||||||
|
df["h6_prev_close"] = closed_m["h6_prev_close"]
|
||||||
|
engulf_time = closed_m["engulf_time"].to_numpy(dtype=float)
|
||||||
|
|
||||||
|
ts_ms = epoch_ms(df.index)
|
||||||
|
engulf_state = df["h6_engulf_state"].to_numpy()
|
||||||
|
for i in range(len(df)):
|
||||||
|
et = engulf_time[i]
|
||||||
|
if not np.isnan(et) and (ts_ms[i] - et) > p.engulf_reset_ms:
|
||||||
|
engulf_state[i] = ""
|
||||||
|
df["h6_engulf_state"] = engulf_state
|
||||||
|
|
||||||
|
bull_arr = (df["bull_breakout"] | (df["h6_engulf_state"] == "bull")).to_numpy(dtype=bool)
|
||||||
|
bear_arr = (df["bear_breakout"] | (df["h6_engulf_state"] == "bear")).to_numpy(dtype=bool)
|
||||||
|
last_eng = np.empty(len(df), dtype=object)
|
||||||
|
last_eng[:] = ""
|
||||||
|
cur = ""
|
||||||
|
for i in range(len(df)):
|
||||||
|
b = bull_arr[i]
|
||||||
|
s = bear_arr[i]
|
||||||
|
if b and not s:
|
||||||
|
cur = "bull"
|
||||||
|
elif s and not b:
|
||||||
|
cur = "bear"
|
||||||
|
last_eng[i] = cur
|
||||||
|
df["last_eng"] = last_eng
|
||||||
|
|
||||||
|
alma_up = (df["alma"] > df["alma"].shift(1)) & (df["alma"].shift(1) > df["alma"].shift(2))
|
||||||
|
alma_down = (df["alma"] < df["alma"].shift(1)) & (df["alma"].shift(1) < df["alma"].shift(2))
|
||||||
|
alma_6h_long = (df["h6_closed_close"] > df["alma"]) & (
|
||||||
|
df["h6_prev_close"] > df["alma"].shift(1)
|
||||||
|
)
|
||||||
|
alma_6h_short = (df["h6_closed_close"] < df["alma"]) & (
|
||||||
|
df["h6_prev_close"] < df["alma"].shift(1)
|
||||||
|
)
|
||||||
|
buy_6h_ok = df["rsi_6h"] < p.rsi_6h_overbought
|
||||||
|
sell_6h_ok = df["rsi_6h"] > p.rsi_6h_oversold
|
||||||
|
|
||||||
|
buy_sig = np.zeros(len(df), dtype=bool)
|
||||||
|
sell_sig = np.zeros(len(df), dtype=bool)
|
||||||
|
last_buy_time = np.nan
|
||||||
|
last_sell_time = np.nan
|
||||||
|
open_side = ""
|
||||||
|
long_sl_px = np.nan
|
||||||
|
short_sl_px = np.nan
|
||||||
|
close_a = df["close"].to_numpy(dtype=float)
|
||||||
|
low_a = df["low"].to_numpy(dtype=float)
|
||||||
|
high_a = df["high"].to_numpy(dtype=float)
|
||||||
|
buy_x = df["buy_crossover"].fillna(False).to_numpy(dtype=bool)
|
||||||
|
sell_x = df["sell_crossover"].fillna(False).to_numpy(dtype=bool)
|
||||||
|
alma_up_a = alma_up.fillna(False).to_numpy(dtype=bool)
|
||||||
|
alma_down_a = alma_down.fillna(False).to_numpy(dtype=bool)
|
||||||
|
alma_6h_long_a = alma_6h_long.fillna(False).to_numpy(dtype=bool)
|
||||||
|
alma_6h_short_a = alma_6h_short.fillna(False).to_numpy(dtype=bool)
|
||||||
|
buy_6h_a = buy_6h_ok.fillna(False).to_numpy(dtype=bool)
|
||||||
|
sell_6h_a = sell_6h_ok.fillna(False).to_numpy(dtype=bool)
|
||||||
|
long_sl_arr = df["low_16h"].to_numpy(dtype=float)
|
||||||
|
short_sl_arr = df["high_16h"].to_numpy(dtype=float)
|
||||||
|
|
||||||
|
for i in range(len(df)):
|
||||||
|
if not np.isnan(long_sl_px) and (close_a[i] < long_sl_px or low_a[i] <= long_sl_px):
|
||||||
|
long_sl_px = np.nan
|
||||||
|
if open_side == "buy":
|
||||||
|
open_side = ""
|
||||||
|
if not np.isnan(short_sl_px) and (close_a[i] > short_sl_px or high_a[i] >= short_sl_px):
|
||||||
|
short_sl_px = np.nan
|
||||||
|
if open_side == "sell":
|
||||||
|
open_side = ""
|
||||||
|
|
||||||
|
ts = ts_ms[i]
|
||||||
|
buy_cd = np.isnan(last_buy_time) or (ts - last_buy_time >= p.cooldown_ms)
|
||||||
|
sell_cd = np.isnan(last_sell_time) or (ts - last_sell_time >= p.cooldown_ms)
|
||||||
|
|
||||||
|
is_buy = (
|
||||||
|
buy_x[i]
|
||||||
|
and open_side != "buy"
|
||||||
|
and last_eng[i] == "bull"
|
||||||
|
and (alma_up_a[i] or alma_6h_long_a[i])
|
||||||
|
and buy_cd
|
||||||
|
and buy_6h_a[i]
|
||||||
|
)
|
||||||
|
is_sell = (
|
||||||
|
sell_x[i]
|
||||||
|
and open_side != "sell"
|
||||||
|
and last_eng[i] == "bear"
|
||||||
|
and (alma_down_a[i] or alma_6h_short_a[i])
|
||||||
|
and sell_cd
|
||||||
|
and sell_6h_a[i]
|
||||||
|
)
|
||||||
|
|
||||||
|
if is_buy:
|
||||||
|
buy_sig[i] = True
|
||||||
|
last_buy_time = ts
|
||||||
|
open_side = "buy"
|
||||||
|
long_sl_px = long_sl_arr[i]
|
||||||
|
short_sl_px = np.nan
|
||||||
|
if is_sell:
|
||||||
|
sell_sig[i] = True
|
||||||
|
last_sell_time = ts
|
||||||
|
open_side = "sell"
|
||||||
|
short_sl_px = short_sl_arr[i]
|
||||||
|
long_sl_px = np.nan
|
||||||
|
|
||||||
|
df["buy_sig"] = buy_sig
|
||||||
|
df["sell_sig"] = sell_sig
|
||||||
|
df["long_sl"] = df["low_16h"]
|
||||||
|
df["short_sl"] = df["high_16h"]
|
||||||
|
df["long_tp1"] = df["close"] + (df["close"] - df["long_sl"]) * p.tp1_rr
|
||||||
|
df["long_tp2"] = df["close"] + (df["close"] - df["long_sl"]) * p.tp2_rr
|
||||||
|
df["long_tp3"] = df["close"] + (df["close"] - df["long_sl"]) * p.tp3_rr
|
||||||
|
df["short_tp1"] = df["close"] - (df["short_sl"] - df["close"]) * p.tp1_rr
|
||||||
|
df["short_tp2"] = df["close"] - (df["short_sl"] - df["close"]) * p.tp2_rr
|
||||||
|
df["short_tp3"] = df["close"] - (df["short_sl"] - df["close"]) * p.tp3_rr
|
||||||
|
return df
|
||||||
|
|
||||||
|
|
||||||
|
def last_ltf_signal(df: pd.DataFrame, params: LtfParams | None = None) -> IndicatorSignal | None:
|
||||||
|
if df.empty:
|
||||||
|
return None
|
||||||
|
p = params or LtfParams()
|
||||||
|
last = df.iloc[-1]
|
||||||
|
ts = int(df.index[-1].timestamp())
|
||||||
|
close = float(last["close"])
|
||||||
|
if bool(last["buy_sig"]):
|
||||||
|
sl = float(last["long_sl"])
|
||||||
|
if np.isnan(sl) or np.isnan(close):
|
||||||
|
return None
|
||||||
|
size = calc_size(close, sl, p.risk_usd)
|
||||||
|
if np.isnan(size):
|
||||||
|
return None
|
||||||
|
return IndicatorSignal(
|
||||||
|
strategy_id="ltf",
|
||||||
|
side="long",
|
||||||
|
entry=close,
|
||||||
|
close=close,
|
||||||
|
sl=sl,
|
||||||
|
tp1=float(last["long_tp1"]),
|
||||||
|
tp2=float(last["long_tp2"]),
|
||||||
|
tp3=float(last["long_tp3"]),
|
||||||
|
size_usd=size,
|
||||||
|
bar_open_ts=ts,
|
||||||
|
visual_timeframe=p.visual_timeframe,
|
||||||
|
)
|
||||||
|
if bool(last["sell_sig"]):
|
||||||
|
sl = float(last["short_sl"])
|
||||||
|
if np.isnan(sl) or np.isnan(close):
|
||||||
|
return None
|
||||||
|
size = calc_size(close, sl, p.risk_usd)
|
||||||
|
if np.isnan(size):
|
||||||
|
return None
|
||||||
|
return IndicatorSignal(
|
||||||
|
strategy_id="ltf",
|
||||||
|
side="short",
|
||||||
|
entry=close,
|
||||||
|
close=close,
|
||||||
|
sl=sl,
|
||||||
|
tp1=float(last["short_tp1"]),
|
||||||
|
tp2=float(last["short_tp2"]),
|
||||||
|
tp3=float(last["short_tp3"]),
|
||||||
|
size_usd=size,
|
||||||
|
bar_open_ts=ts,
|
||||||
|
visual_timeframe=p.visual_timeframe,
|
||||||
|
)
|
||||||
|
return None
|
||||||
103
app/main.py
103
app/main.py
|
|
@ -4,18 +4,16 @@ import asyncio
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import secrets
|
import secrets
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import BackgroundTasks, FastAPI, HTTPException, Request
|
from fastapi import BackgroundTasks, FastAPI, HTTPException, Request
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
from pydantic import ValidationError
|
from pydantic import ValidationError
|
||||||
|
|
||||||
from app.binance import fetch_klines, to_binance_interval, to_binance_symbol
|
|
||||||
from app.chart import render_setup_chart
|
|
||||||
from app.config import Settings, get_settings
|
from app.config import Settings, get_settings
|
||||||
from app.formatter import format_caption
|
|
||||||
from app.models import SignalPayload
|
from app.models import SignalPayload
|
||||||
from app.telegram import TelegramError, send_message, send_photo
|
from app.pipeline import deliver_telegram
|
||||||
|
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
level=logging.INFO,
|
level=logging.INFO,
|
||||||
|
|
@ -23,12 +21,37 @@ logging.basicConfig(
|
||||||
)
|
)
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(_app: FastAPI):
|
||||||
|
settings = get_settings()
|
||||||
|
task: asyncio.Task[None] | None = None
|
||||||
|
if settings.scanner_enabled:
|
||||||
|
from app.scanner import run_scanner
|
||||||
|
|
||||||
|
task = asyncio.create_task(run_scanner(settings))
|
||||||
|
logger.info(
|
||||||
|
"Scanner enabled watchlist=%s poll=%ss",
|
||||||
|
settings.watchlist_path,
|
||||||
|
settings.scanner_poll_seconds,
|
||||||
|
)
|
||||||
|
yield
|
||||||
|
if task is not None:
|
||||||
|
task.cancel()
|
||||||
|
try:
|
||||||
|
await task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
logger.info("Scanner stopped")
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="TV Signals → Telegram",
|
title="TV Signals → Telegram",
|
||||||
version="1.0.0",
|
version="1.0.0",
|
||||||
docs_url=None,
|
docs_url=None,
|
||||||
redoc_url=None,
|
redoc_url=None,
|
||||||
openapi_url=None,
|
openapi_url=None,
|
||||||
|
lifespan=lifespan,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -53,76 +76,6 @@ def _parse_signal_body(raw: bytes) -> SignalPayload:
|
||||||
raise HTTPException(status_code=422, detail=json.loads(exc.json())) from exc
|
raise HTTPException(status_code=422, detail=json.loads(exc.json())) from exc
|
||||||
|
|
||||||
|
|
||||||
async def _deliver_signal(
|
|
||||||
settings: Settings,
|
|
||||||
signal: SignalPayload,
|
|
||||||
message_thread_id: int,
|
|
||||||
) -> None:
|
|
||||||
"""Fetch chart + post to Telegram after the webhook HTTP response is sent."""
|
|
||||||
caption = format_caption(signal)
|
|
||||||
photo: bytes | None = None
|
|
||||||
chart_error: str | None = None
|
|
||||||
|
|
||||||
try:
|
|
||||||
symbol = to_binance_symbol(signal.ticker)
|
|
||||||
interval = to_binance_interval(signal.visual_timeframe)
|
|
||||||
df = await fetch_klines(symbol, interval)
|
|
||||||
photo = await asyncio.to_thread(
|
|
||||||
render_setup_chart,
|
|
||||||
df,
|
|
||||||
ticker=signal.ticker,
|
|
||||||
action=signal.action.value,
|
|
||||||
entry=signal.entry_price,
|
|
||||||
stop_loss=signal.stop_loss_price,
|
|
||||||
tp1=signal.take_profit_1_price,
|
|
||||||
tp2=signal.take_profit_2_price,
|
|
||||||
tp3=signal.take_profit_3_price,
|
|
||||||
timeframe=signal.visual_timeframe,
|
|
||||||
current_price=(
|
|
||||||
signal.current_price if signal.signal_sequence > 1 else None
|
|
||||||
),
|
|
||||||
signal_time=signal.signal_time,
|
|
||||||
)
|
|
||||||
except Exception as exc: # noqa: BLE001 — fallback to text-only post
|
|
||||||
chart_error = str(exc)
|
|
||||||
logger.exception("Chart generation failed, falling back to text-only: %s", exc)
|
|
||||||
|
|
||||||
try:
|
|
||||||
if photo is not None:
|
|
||||||
await send_photo(
|
|
||||||
settings,
|
|
||||||
photo=photo,
|
|
||||||
caption=caption,
|
|
||||||
message_thread_id=message_thread_id,
|
|
||||||
)
|
|
||||||
logger.info(
|
|
||||||
"Delivered photo: %s %s seq=%s thread=%s",
|
|
||||||
signal.ticker,
|
|
||||||
signal.action.value,
|
|
||||||
signal.signal_sequence,
|
|
||||||
message_thread_id,
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
await send_message(
|
|
||||||
settings,
|
|
||||||
text=caption,
|
|
||||||
message_thread_id=message_thread_id,
|
|
||||||
)
|
|
||||||
logger.info(
|
|
||||||
"Delivered text: %s %s seq=%s thread=%s chart_error=%s",
|
|
||||||
signal.ticker,
|
|
||||||
signal.action.value,
|
|
||||||
signal.signal_sequence,
|
|
||||||
message_thread_id,
|
|
||||||
chart_error,
|
|
||||||
)
|
|
||||||
except TelegramError as exc:
|
|
||||||
logger.exception("Telegram delivery failed: %s", exc)
|
|
||||||
except Exception as exc: # noqa: BLE001
|
|
||||||
logger.exception("Unexpected delivery error: %s", exc)
|
|
||||||
|
|
||||||
|
|
||||||
@app.api_route("/health", methods=["GET", "HEAD"])
|
@app.api_route("/health", methods=["GET", "HEAD"])
|
||||||
async def health() -> dict[str, str]:
|
async def health() -> dict[str, str]:
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|
@ -153,7 +106,7 @@ async def _handle_webhook(
|
||||||
)
|
)
|
||||||
|
|
||||||
background_tasks.add_task(
|
background_tasks.add_task(
|
||||||
_deliver_signal,
|
deliver_telegram,
|
||||||
settings,
|
settings,
|
||||||
signal,
|
signal,
|
||||||
message_thread_id,
|
message_thread_id,
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,7 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
from pydantic import BaseModel, Field, field_validator
|
from pydantic import BaseModel, Field, field_validator
|
||||||
|
|
||||||
|
|
@ -15,11 +18,16 @@ class SignalPayload(BaseModel):
|
||||||
current_price: str
|
current_price: str
|
||||||
stop_loss_price: str
|
stop_loss_price: str
|
||||||
take_profit_1_price: str
|
take_profit_1_price: str
|
||||||
take_profit_2_price: str
|
take_profit_2_price: Optional[str] = None
|
||||||
take_profit_3_price: str
|
take_profit_3_price: Optional[str] = None
|
||||||
visual_timeframe: str
|
visual_timeframe: str
|
||||||
signal_sequence: int = Field(ge=1)
|
signal_sequence: int = Field(ge=1)
|
||||||
signal_time: int = Field(gt=0) # unix seconds of seq==1 bar open (UTC)
|
signal_time: int = Field(gt=0) # unix seconds of seq==1 bar open (UTC)
|
||||||
|
is_reversal: bool = False
|
||||||
|
realized_pnl_pct: Optional[float] = None
|
||||||
|
prev_side: Optional[str] = None
|
||||||
|
prev_entry_price: Optional[str] = None
|
||||||
|
prev_signal_time: Optional[int] = None
|
||||||
|
|
||||||
@field_validator("action", mode="before")
|
@field_validator("action", mode="before")
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|
@ -34,8 +42,6 @@ class SignalPayload(BaseModel):
|
||||||
"current_price",
|
"current_price",
|
||||||
"stop_loss_price",
|
"stop_loss_price",
|
||||||
"take_profit_1_price",
|
"take_profit_1_price",
|
||||||
"take_profit_2_price",
|
|
||||||
"take_profit_3_price",
|
|
||||||
"visual_timeframe",
|
"visual_timeframe",
|
||||||
mode="before",
|
mode="before",
|
||||||
)
|
)
|
||||||
|
|
@ -44,3 +50,14 @@ class SignalPayload(BaseModel):
|
||||||
if isinstance(value, str):
|
if isinstance(value, str):
|
||||||
return value.strip()
|
return value.strip()
|
||||||
return value
|
return value
|
||||||
|
|
||||||
|
@field_validator("take_profit_2_price", "take_profit_3_price", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def empty_optional_price(cls, value: object) -> object:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
if isinstance(value, str) and not value.strip():
|
||||||
|
return None
|
||||||
|
if isinstance(value, str):
|
||||||
|
return value.strip()
|
||||||
|
return value
|
||||||
|
|
|
||||||
181
app/pipeline.py
Normal file
181
app/pipeline.py
Normal file
|
|
@ -0,0 +1,181 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from app.binance import (
|
||||||
|
chart_kline_limit,
|
||||||
|
chart_right_pad,
|
||||||
|
fetch_klines,
|
||||||
|
get_tick_size,
|
||||||
|
interval_timedelta,
|
||||||
|
to_binance_interval,
|
||||||
|
to_binance_symbol,
|
||||||
|
)
|
||||||
|
from app.chart import render_setup_chart
|
||||||
|
from app.config import Settings
|
||||||
|
from app.formatter import format_caption
|
||||||
|
from app.heryon import build_heryon_payload, send_heryon, to_tv_perp_ticker
|
||||||
|
from app.indicators.common import IndicatorSignal, format_px, realized_pnl_pct
|
||||||
|
from app.models import SignalPayload
|
||||||
|
from app.state import ScannerStore
|
||||||
|
from app.telegram import TelegramError, send_message, send_photo, telegram_message_id
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def to_telegram_payload(signal: IndicatorSignal, ticker: str) -> SignalPayload:
|
||||||
|
tick = get_tick_size(ticker)
|
||||||
|
return SignalPayload(
|
||||||
|
ticker=ticker,
|
||||||
|
action=signal.side,
|
||||||
|
entry_price=format_px(signal.entry, tick),
|
||||||
|
current_price=format_px(signal.close, tick),
|
||||||
|
stop_loss_price=format_px(signal.sl, tick),
|
||||||
|
take_profit_1_price=format_px(signal.tp1, tick),
|
||||||
|
take_profit_2_price=format_px(signal.tp2, tick) or None,
|
||||||
|
take_profit_3_price=format_px(signal.tp3, tick) or None,
|
||||||
|
visual_timeframe=signal.visual_timeframe,
|
||||||
|
signal_sequence=1,
|
||||||
|
signal_time=signal.bar_open_ts,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def deliver_telegram(
|
||||||
|
settings: Settings,
|
||||||
|
signal: SignalPayload,
|
||||||
|
message_thread_id: int,
|
||||||
|
*,
|
||||||
|
store: ScannerStore | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Fetch chart + post to Telegram (used by inbound webhook and scanner)."""
|
||||||
|
caption = format_caption(signal)
|
||||||
|
photo: bytes | None = None
|
||||||
|
chart_error: str | None = None
|
||||||
|
owns_store = store is None
|
||||||
|
db = store or ScannerStore(settings.scanner_state_path)
|
||||||
|
reply_id = db.get_last_tg_message(message_thread_id, signal.ticker)
|
||||||
|
|
||||||
|
try:
|
||||||
|
symbol = to_binance_symbol(signal.ticker)
|
||||||
|
interval = to_binance_interval(signal.visual_timeframe)
|
||||||
|
limit = chart_kline_limit(interval)
|
||||||
|
if signal.prev_signal_time and signal.prev_signal_time > 0:
|
||||||
|
bar_sec = interval_timedelta(interval).total_seconds()
|
||||||
|
if bar_sec > 0:
|
||||||
|
span = int((signal.signal_time - signal.prev_signal_time) / bar_sec) + 24
|
||||||
|
limit = min(1500, max(limit, span))
|
||||||
|
df = await fetch_klines(symbol, interval, limit=limit)
|
||||||
|
# LTF 15m: chart shows TP1 + remainder green (like FVG); caption still has TP2/TP3.
|
||||||
|
chart_tp2 = None if interval == "15m" else signal.take_profit_2_price
|
||||||
|
chart_tp3 = None if interval == "15m" else signal.take_profit_3_price
|
||||||
|
photo = await asyncio.to_thread(
|
||||||
|
render_setup_chart,
|
||||||
|
df,
|
||||||
|
ticker=signal.ticker,
|
||||||
|
action=signal.action.value,
|
||||||
|
entry=signal.entry_price,
|
||||||
|
stop_loss=signal.stop_loss_price,
|
||||||
|
tp1=signal.take_profit_1_price,
|
||||||
|
tp2=chart_tp2,
|
||||||
|
tp3=chart_tp3,
|
||||||
|
timeframe=signal.visual_timeframe,
|
||||||
|
current_price=(
|
||||||
|
signal.current_price if signal.signal_sequence > 1 else None
|
||||||
|
),
|
||||||
|
signal_time=signal.signal_time,
|
||||||
|
right_pad=chart_right_pad(interval),
|
||||||
|
prev_entry=signal.prev_entry_price,
|
||||||
|
prev_side=signal.prev_side,
|
||||||
|
prev_signal_time=signal.prev_signal_time,
|
||||||
|
)
|
||||||
|
except Exception as exc: # noqa: BLE001 — fallback to text-only post
|
||||||
|
chart_error = str(exc)
|
||||||
|
logger.exception("Chart generation failed, falling back to text-only: %s", exc)
|
||||||
|
|
||||||
|
try:
|
||||||
|
if photo is not None:
|
||||||
|
response = await send_photo(
|
||||||
|
settings,
|
||||||
|
photo=photo,
|
||||||
|
caption=caption,
|
||||||
|
message_thread_id=message_thread_id,
|
||||||
|
reply_to_message_id=reply_id,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
response = await send_message(
|
||||||
|
settings,
|
||||||
|
text=caption,
|
||||||
|
message_thread_id=message_thread_id,
|
||||||
|
reply_to_message_id=reply_id,
|
||||||
|
)
|
||||||
|
message_id = telegram_message_id(response)
|
||||||
|
if message_id is not None:
|
||||||
|
db.set_last_tg_message(message_thread_id, signal.ticker, message_id)
|
||||||
|
logger.info(
|
||||||
|
"Delivered %s: %s %s seq=%s thread=%s chart_error=%s",
|
||||||
|
"photo" if photo is not None else "text",
|
||||||
|
signal.ticker,
|
||||||
|
signal.action.value,
|
||||||
|
signal.signal_sequence,
|
||||||
|
message_thread_id,
|
||||||
|
chart_error,
|
||||||
|
)
|
||||||
|
except TelegramError as exc:
|
||||||
|
logger.exception("Telegram delivery failed: %s", exc)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
logger.exception("Unexpected delivery error: %s", exc)
|
||||||
|
finally:
|
||||||
|
if owns_store:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
async def deliver_generated(
|
||||||
|
settings: Settings,
|
||||||
|
signal: IndicatorSignal,
|
||||||
|
symbol: str,
|
||||||
|
message_thread_id: int,
|
||||||
|
*,
|
||||||
|
store: ScannerStore | None = None,
|
||||||
|
) -> None:
|
||||||
|
ticker = to_tv_perp_ticker(symbol)
|
||||||
|
telegram_payload = to_telegram_payload(signal, ticker)
|
||||||
|
if store is not None:
|
||||||
|
previous = store.get_last_open(signal.strategy_id, symbol)
|
||||||
|
if previous is not None:
|
||||||
|
prev_side, prev_entry, prev_ts = previous
|
||||||
|
if prev_side != signal.side:
|
||||||
|
tick = get_tick_size(ticker)
|
||||||
|
telegram_payload = telegram_payload.model_copy(
|
||||||
|
update={
|
||||||
|
"is_reversal": True,
|
||||||
|
"realized_pnl_pct": realized_pnl_pct(
|
||||||
|
prev_side, prev_entry, signal.entry
|
||||||
|
),
|
||||||
|
"prev_side": prev_side,
|
||||||
|
"prev_entry_price": format_px(prev_entry, tick),
|
||||||
|
"prev_signal_time": prev_ts or None,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
heryon_payload = build_heryon_payload(signal, symbol=symbol, settings=settings)
|
||||||
|
|
||||||
|
await deliver_telegram(
|
||||||
|
settings,
|
||||||
|
telegram_payload,
|
||||||
|
message_thread_id,
|
||||||
|
store=store,
|
||||||
|
)
|
||||||
|
if store is not None:
|
||||||
|
store.set_last_open(
|
||||||
|
signal.strategy_id,
|
||||||
|
symbol,
|
||||||
|
signal.side,
|
||||||
|
signal.entry,
|
||||||
|
signal.bar_open_ts,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
await send_heryon(settings, heryon_payload)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
logger.exception(
|
||||||
|
"Heryon send failed nonce=%s: %s", heryon_payload.get("nonce"), exc
|
||||||
|
)
|
||||||
164
app/scanner.py
Normal file
164
app/scanner.py
Normal file
|
|
@ -0,0 +1,164 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from app.binance import fetch_klines, load_tick_sizes, to_binance_symbol
|
||||||
|
from app.config import Settings
|
||||||
|
from app.heryon import heryon_nonce, to_tv_perp_ticker
|
||||||
|
from app.indicators.fvg import FvgParams, evaluate_fvg, last_fvg_signal
|
||||||
|
from app.indicators.ltf import LtfParams, attach_forming_6h, evaluate_ltf, last_ltf_signal
|
||||||
|
from app.pipeline import deliver_generated
|
||||||
|
from app.state import ScannerStore
|
||||||
|
from app.watchlist import Watchlist, load_watchlist
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
LTF_15M_BARS = 600
|
||||||
|
LTF_6H_BARS = 250
|
||||||
|
FVG_6H_BARS = 400
|
||||||
|
|
||||||
|
|
||||||
|
async def run_scanner(settings: Settings) -> None:
|
||||||
|
store = ScannerStore(settings.scanner_state_path)
|
||||||
|
try:
|
||||||
|
await load_tick_sizes()
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
watchlist = load_watchlist(settings.watchlist_path)
|
||||||
|
await scan_once(settings, watchlist, store)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Scanner cycle failed")
|
||||||
|
await asyncio.sleep(settings.scanner_poll_seconds)
|
||||||
|
finally:
|
||||||
|
store.close()
|
||||||
|
|
||||||
|
|
||||||
|
async def scan_once(
|
||||||
|
settings: Settings,
|
||||||
|
watchlist: Watchlist,
|
||||||
|
store: ScannerStore,
|
||||||
|
) -> None:
|
||||||
|
ltf_cfg = watchlist.strategies.get("ltf")
|
||||||
|
fvg_cfg = watchlist.strategies.get("fvg")
|
||||||
|
async with httpx.AsyncClient(timeout=20.0) as client:
|
||||||
|
for symbol in watchlist.symbols:
|
||||||
|
pair = to_binance_symbol(symbol)
|
||||||
|
if ltf_cfg is not None and ltf_cfg.enabled:
|
||||||
|
try:
|
||||||
|
await _scan_ltf(settings, store, client, pair, ltf_cfg.telegram_thread_id)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("LTF scan failed for %s", pair)
|
||||||
|
if fvg_cfg is not None and fvg_cfg.enabled:
|
||||||
|
try:
|
||||||
|
await _scan_fvg(settings, store, client, pair, fvg_cfg.telegram_thread_id)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("FVG scan failed for %s", pair)
|
||||||
|
|
||||||
|
|
||||||
|
def _thread(settings: Settings, override: int | None) -> int:
|
||||||
|
if override is not None and override >= 1:
|
||||||
|
return override
|
||||||
|
return settings.telegram_message_thread_id
|
||||||
|
|
||||||
|
|
||||||
|
async def _scan_ltf(
|
||||||
|
settings: Settings,
|
||||||
|
store: ScannerStore,
|
||||||
|
client: httpx.AsyncClient,
|
||||||
|
symbol: str,
|
||||||
|
thread_id: int | None,
|
||||||
|
) -> None:
|
||||||
|
df_15m = await fetch_klines(
|
||||||
|
symbol, "15m", limit=LTF_15M_BARS, closed_only=True, client=client
|
||||||
|
)
|
||||||
|
if df_15m.empty:
|
||||||
|
return
|
||||||
|
bar_ts = int(df_15m.index[-1].timestamp())
|
||||||
|
last = store.get_last_bar("ltf", symbol)
|
||||||
|
if last is None:
|
||||||
|
store.set_last_bar("ltf", symbol, bar_ts)
|
||||||
|
logger.info("LTF primed %s at %s (skip history)", symbol, bar_ts)
|
||||||
|
return
|
||||||
|
if bar_ts <= last:
|
||||||
|
return
|
||||||
|
|
||||||
|
df_6h_closed = await fetch_klines(
|
||||||
|
symbol, "6h", limit=LTF_6H_BARS, closed_only=True, client=client
|
||||||
|
)
|
||||||
|
if df_6h_closed.empty:
|
||||||
|
logger.warning("LTF %s: empty 6h klines", symbol)
|
||||||
|
return
|
||||||
|
df_6h = attach_forming_6h(df_6h_closed, df_15m)
|
||||||
|
params = LtfParams(risk_usd=settings.risk_usd)
|
||||||
|
analyzed = await asyncio.to_thread(evaluate_ltf, df_15m, df_6h, params)
|
||||||
|
signal = last_ltf_signal(analyzed, params)
|
||||||
|
await _emit_if_new(settings, store, "ltf", symbol, bar_ts, signal, thread_id)
|
||||||
|
|
||||||
|
|
||||||
|
async def _scan_fvg(
|
||||||
|
settings: Settings,
|
||||||
|
store: ScannerStore,
|
||||||
|
client: httpx.AsyncClient,
|
||||||
|
symbol: str,
|
||||||
|
thread_id: int | None,
|
||||||
|
) -> None:
|
||||||
|
df_6h = await fetch_klines(
|
||||||
|
symbol, "6h", limit=FVG_6H_BARS, closed_only=True, client=client
|
||||||
|
)
|
||||||
|
if df_6h.empty:
|
||||||
|
return
|
||||||
|
bar_ts = int(df_6h.index[-1].timestamp())
|
||||||
|
last = store.get_last_bar("fvg", symbol)
|
||||||
|
if last is None:
|
||||||
|
store.set_last_bar("fvg", symbol, bar_ts)
|
||||||
|
logger.info("FVG primed %s at %s (skip history)", symbol, bar_ts)
|
||||||
|
return
|
||||||
|
if bar_ts <= last:
|
||||||
|
return
|
||||||
|
|
||||||
|
params = FvgParams(risk_usd=settings.risk_usd)
|
||||||
|
analyzed = await asyncio.to_thread(evaluate_fvg, df_6h, params)
|
||||||
|
signal = last_fvg_signal(analyzed, params)
|
||||||
|
await _emit_if_new(settings, store, "fvg", symbol, bar_ts, signal, thread_id)
|
||||||
|
|
||||||
|
|
||||||
|
async def _emit_if_new(
|
||||||
|
settings: Settings,
|
||||||
|
store: ScannerStore,
|
||||||
|
strategy: str,
|
||||||
|
symbol: str,
|
||||||
|
bar_ts: int,
|
||||||
|
signal,
|
||||||
|
thread_id: int | None,
|
||||||
|
) -> None:
|
||||||
|
if signal is None:
|
||||||
|
store.set_last_bar(strategy, symbol, bar_ts)
|
||||||
|
logger.debug("%s %s new bar %s — no signal", strategy, symbol, bar_ts)
|
||||||
|
return
|
||||||
|
|
||||||
|
ticker = to_tv_perp_ticker(symbol)
|
||||||
|
nonce = heryon_nonce(ticker, signal.side, signal.bar_open_ts)
|
||||||
|
if store.nonce_sent(nonce):
|
||||||
|
store.set_last_bar(strategy, symbol, bar_ts)
|
||||||
|
logger.info("Skip duplicate nonce %s", nonce)
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Signal %s %s %s bar=%s sl=%s tp1=%s",
|
||||||
|
strategy,
|
||||||
|
symbol,
|
||||||
|
signal.side,
|
||||||
|
bar_ts,
|
||||||
|
signal.sl,
|
||||||
|
signal.tp1,
|
||||||
|
)
|
||||||
|
await deliver_generated(
|
||||||
|
settings, signal, symbol, _thread(settings, thread_id), store=store
|
||||||
|
)
|
||||||
|
store.mark_nonce(nonce)
|
||||||
|
store.set_last_bar(strategy, symbol, bar_ts)
|
||||||
145
app/state.py
Normal file
145
app/state.py
Normal file
|
|
@ -0,0 +1,145 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
class ScannerStore:
|
||||||
|
"""Persist last processed bar per (strategy, symbol) and sent Heryon nonces."""
|
||||||
|
|
||||||
|
def __init__(self, path: str | Path) -> None:
|
||||||
|
self._path = Path(path)
|
||||||
|
self._path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
self._conn = sqlite3.connect(self._path, check_same_thread=False)
|
||||||
|
self._conn.execute("PRAGMA journal_mode=WAL")
|
||||||
|
self._conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS processed (
|
||||||
|
strategy TEXT NOT NULL,
|
||||||
|
symbol TEXT NOT NULL,
|
||||||
|
bar_open_ts INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY (strategy, symbol)
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
self._conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS sent_nonces (
|
||||||
|
nonce TEXT PRIMARY KEY,
|
||||||
|
sent_at INTEGER NOT NULL
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
self._conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS last_open (
|
||||||
|
strategy TEXT NOT NULL,
|
||||||
|
symbol TEXT NOT NULL,
|
||||||
|
side TEXT NOT NULL,
|
||||||
|
entry REAL NOT NULL,
|
||||||
|
bar_open_ts INTEGER NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (strategy, symbol)
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
self._conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS last_tg_message (
|
||||||
|
thread_id INTEGER NOT NULL,
|
||||||
|
ticker TEXT NOT NULL,
|
||||||
|
message_id INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY (thread_id, ticker)
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
last_open_cols = {
|
||||||
|
row[1] for row in self._conn.execute("PRAGMA table_info(last_open)")
|
||||||
|
}
|
||||||
|
if "bar_open_ts" not in last_open_cols:
|
||||||
|
self._conn.execute(
|
||||||
|
"ALTER TABLE last_open ADD COLUMN bar_open_ts INTEGER NOT NULL DEFAULT 0"
|
||||||
|
)
|
||||||
|
self._conn.commit()
|
||||||
|
|
||||||
|
def get_last_bar(self, strategy: str, symbol: str) -> int | None:
|
||||||
|
row = self._conn.execute(
|
||||||
|
"SELECT bar_open_ts FROM processed WHERE strategy = ? AND symbol = ?",
|
||||||
|
(strategy, symbol),
|
||||||
|
).fetchone()
|
||||||
|
return int(row[0]) if row else None
|
||||||
|
|
||||||
|
def set_last_bar(self, strategy: str, symbol: str, bar_open_ts: int) -> None:
|
||||||
|
self._conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO processed (strategy, symbol, bar_open_ts)
|
||||||
|
VALUES (?, ?, ?)
|
||||||
|
ON CONFLICT(strategy, symbol) DO UPDATE SET bar_open_ts = excluded.bar_open_ts
|
||||||
|
""",
|
||||||
|
(strategy, symbol, bar_open_ts),
|
||||||
|
)
|
||||||
|
self._conn.commit()
|
||||||
|
|
||||||
|
def nonce_sent(self, nonce: str) -> bool:
|
||||||
|
row = self._conn.execute(
|
||||||
|
"SELECT 1 FROM sent_nonces WHERE nonce = ?", (nonce,)
|
||||||
|
).fetchone()
|
||||||
|
return row is not None
|
||||||
|
|
||||||
|
def mark_nonce(self, nonce: str) -> None:
|
||||||
|
self._conn.execute(
|
||||||
|
"INSERT OR IGNORE INTO sent_nonces (nonce, sent_at) VALUES (?, ?)",
|
||||||
|
(nonce, int(time.time())),
|
||||||
|
)
|
||||||
|
self._conn.commit()
|
||||||
|
|
||||||
|
def get_last_open(self, strategy: str, symbol: str) -> tuple[str, float, int] | None:
|
||||||
|
row = self._conn.execute(
|
||||||
|
"SELECT side, entry, bar_open_ts FROM last_open WHERE strategy = ? AND symbol = ?",
|
||||||
|
(strategy, symbol),
|
||||||
|
).fetchone()
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
return str(row[0]), float(row[1]), int(row[2] or 0)
|
||||||
|
|
||||||
|
def set_last_open(
|
||||||
|
self,
|
||||||
|
strategy: str,
|
||||||
|
symbol: str,
|
||||||
|
side: str,
|
||||||
|
entry: float,
|
||||||
|
bar_open_ts: int,
|
||||||
|
) -> None:
|
||||||
|
self._conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO last_open (strategy, symbol, side, entry, bar_open_ts)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(strategy, symbol) DO UPDATE SET
|
||||||
|
side = excluded.side,
|
||||||
|
entry = excluded.entry,
|
||||||
|
bar_open_ts = excluded.bar_open_ts
|
||||||
|
""",
|
||||||
|
(strategy, symbol, side, entry, bar_open_ts),
|
||||||
|
)
|
||||||
|
self._conn.commit()
|
||||||
|
|
||||||
|
def get_last_tg_message(self, thread_id: int, ticker: str) -> int | None:
|
||||||
|
row = self._conn.execute(
|
||||||
|
"SELECT message_id FROM last_tg_message WHERE thread_id = ? AND ticker = ?",
|
||||||
|
(thread_id, ticker.strip().upper()),
|
||||||
|
).fetchone()
|
||||||
|
return int(row[0]) if row else None
|
||||||
|
|
||||||
|
def set_last_tg_message(self, thread_id: int, ticker: str, message_id: int) -> None:
|
||||||
|
self._conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO last_tg_message (thread_id, ticker, message_id)
|
||||||
|
VALUES (?, ?, ?)
|
||||||
|
ON CONFLICT(thread_id, ticker) DO UPDATE SET message_id = excluded.message_id
|
||||||
|
""",
|
||||||
|
(thread_id, ticker.strip().upper(), message_id),
|
||||||
|
)
|
||||||
|
self._conn.commit()
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
self._conn.close()
|
||||||
|
|
@ -15,6 +15,23 @@ class TelegramError(Exception):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def telegram_message_id(payload: dict) -> int | None:
|
||||||
|
result = payload.get("result")
|
||||||
|
if not isinstance(result, dict):
|
||||||
|
return None
|
||||||
|
raw = result.get("message_id")
|
||||||
|
return int(raw) if raw is not None else None
|
||||||
|
|
||||||
|
|
||||||
|
def _reply_fields(reply_to_message_id: int | None) -> dict[str, str]:
|
||||||
|
if reply_to_message_id is None:
|
||||||
|
return {}
|
||||||
|
return {
|
||||||
|
"reply_to_message_id": str(reply_to_message_id),
|
||||||
|
"allow_sending_without_reply": "true",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
async def send_photo(
|
async def send_photo(
|
||||||
settings: Settings,
|
settings: Settings,
|
||||||
*,
|
*,
|
||||||
|
|
@ -22,6 +39,7 @@ async def send_photo(
|
||||||
caption: str,
|
caption: str,
|
||||||
message_thread_id: int,
|
message_thread_id: int,
|
||||||
filename: str = "setup.png",
|
filename: str = "setup.png",
|
||||||
|
reply_to_message_id: int | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
url = f"{TELEGRAM_API}/bot{settings.telegram_bot_token}/sendPhoto"
|
url = f"{TELEGRAM_API}/bot{settings.telegram_bot_token}/sendPhoto"
|
||||||
data = {
|
data = {
|
||||||
|
|
@ -29,6 +47,7 @@ async def send_photo(
|
||||||
"message_thread_id": str(message_thread_id),
|
"message_thread_id": str(message_thread_id),
|
||||||
"caption": caption,
|
"caption": caption,
|
||||||
"parse_mode": "HTML",
|
"parse_mode": "HTML",
|
||||||
|
**_reply_fields(reply_to_message_id),
|
||||||
}
|
}
|
||||||
files = {"photo": (filename, photo, "image/png")}
|
files = {"photo": (filename, photo, "image/png")}
|
||||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||||
|
|
@ -44,6 +63,7 @@ async def send_message(
|
||||||
*,
|
*,
|
||||||
text: str,
|
text: str,
|
||||||
message_thread_id: int,
|
message_thread_id: int,
|
||||||
|
reply_to_message_id: int | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
url = f"{TELEGRAM_API}/bot{settings.telegram_bot_token}/sendMessage"
|
url = f"{TELEGRAM_API}/bot{settings.telegram_bot_token}/sendMessage"
|
||||||
data = {
|
data = {
|
||||||
|
|
@ -52,6 +72,7 @@ async def send_message(
|
||||||
"text": text,
|
"text": text,
|
||||||
"parse_mode": "HTML",
|
"parse_mode": "HTML",
|
||||||
"disable_web_page_preview": True,
|
"disable_web_page_preview": True,
|
||||||
|
**_reply_fields(reply_to_message_id),
|
||||||
}
|
}
|
||||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||||
response = await client.post(url, data=data)
|
response = await client.post(url, data=data)
|
||||||
|
|
|
||||||
54
app/watchlist.py
Normal file
54
app/watchlist.py
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class StrategyWatch:
|
||||||
|
id: str
|
||||||
|
enabled: bool
|
||||||
|
timeframe: str
|
||||||
|
telegram_thread_id: int | None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Watchlist:
|
||||||
|
symbols: list[str]
|
||||||
|
strategies: dict[str, StrategyWatch]
|
||||||
|
|
||||||
|
|
||||||
|
def load_watchlist(path: str | Path) -> Watchlist:
|
||||||
|
raw_path = Path(path)
|
||||||
|
if not raw_path.is_file():
|
||||||
|
raise FileNotFoundError(f"Watchlist not found: {raw_path}")
|
||||||
|
data: Any = yaml.safe_load(raw_path.read_text(encoding="utf-8")) or {}
|
||||||
|
symbols_raw = data.get("symbols") or []
|
||||||
|
symbols = [str(s).strip().upper() for s in symbols_raw if str(s).strip()]
|
||||||
|
if not symbols:
|
||||||
|
raise ValueError("Watchlist has no symbols")
|
||||||
|
|
||||||
|
strategies: dict[str, StrategyWatch] = {}
|
||||||
|
for key, cfg in (data.get("strategies") or {}).items():
|
||||||
|
if not isinstance(cfg, dict):
|
||||||
|
continue
|
||||||
|
strategies[str(key)] = StrategyWatch(
|
||||||
|
id=str(key),
|
||||||
|
enabled=bool(cfg.get("enabled", True)),
|
||||||
|
timeframe=str(cfg.get("timeframe", "")).strip(),
|
||||||
|
telegram_thread_id=_optional_int(cfg.get("telegram_thread_id")),
|
||||||
|
)
|
||||||
|
if "ltf" not in strategies:
|
||||||
|
strategies["ltf"] = StrategyWatch("ltf", True, "15m", None)
|
||||||
|
if "fvg" not in strategies:
|
||||||
|
strategies["fvg"] = StrategyWatch("fvg", True, "6h", None)
|
||||||
|
return Watchlist(symbols=symbols, strategies=strategies)
|
||||||
|
|
||||||
|
|
||||||
|
def _optional_int(value: object) -> int | None:
|
||||||
|
if value is None or value == "":
|
||||||
|
return None
|
||||||
|
return int(value)
|
||||||
0
data/.gitkeep
Normal file
0
data/.gitkeep
Normal file
|
|
@ -9,6 +9,9 @@ services:
|
||||||
HOST: "0.0.0.0"
|
HOST: "0.0.0.0"
|
||||||
PORT: "8000"
|
PORT: "8000"
|
||||||
MPLBACKEND: Agg
|
MPLBACKEND: Agg
|
||||||
|
volumes:
|
||||||
|
- ./watchlist.yaml:/app/watchlist.yaml:ro
|
||||||
|
- ./data:/app/data
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
networks:
|
networks:
|
||||||
default:
|
default:
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@ pydantic>=2.9.0
|
||||||
pydantic-settings>=2.6.0
|
pydantic-settings>=2.6.0
|
||||||
httpx>=0.27.0
|
httpx>=0.27.0
|
||||||
pandas>=2.2.0
|
pandas>=2.2.0
|
||||||
|
numpy>=2.0.0
|
||||||
|
pyyaml>=6.0.2
|
||||||
mplfinance>=0.12.10b0
|
mplfinance>=0.12.10b0
|
||||||
matplotlib>=3.9.0
|
matplotlib>=3.9.0
|
||||||
python-multipart>=0.0.12
|
python-multipart>=0.0.12
|
||||||
|
|
|
||||||
19
watchlist.yaml
Normal file
19
watchlist.yaml
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
# Binance USDT-M perpetual symbols (no .P suffix). Edit without rebuilding if the
|
||||||
|
# file is volume-mounted (see docker-compose.yml).
|
||||||
|
symbols:
|
||||||
|
- BTCUSDT
|
||||||
|
- ETHUSDT
|
||||||
|
- SOLUSDT
|
||||||
|
- ZECUSDT
|
||||||
|
|
||||||
|
strategies:
|
||||||
|
ltf:
|
||||||
|
enabled: true
|
||||||
|
timeframe: 15m
|
||||||
|
# 15m forum topic id (integer from the Telegram topic URL)
|
||||||
|
telegram_thread_id: 51247
|
||||||
|
fvg:
|
||||||
|
enabled: true
|
||||||
|
timeframe: 6h
|
||||||
|
# 6h forum topic id — replace with the other thread
|
||||||
|
telegram_thread_id: 61476
|
||||||
Loading…
Add table
Reference in a new issue