mirror of
https://github.com/ada-dmitry/dca_bot_tinv.git
synced 2026-09-24 00:30:23 +00:00
Добавлено расписание и ежедневный запуск бота с возможностью настройки дня месяца и разрешения сделок в выходные
This commit is contained in:
@@ -82,6 +82,27 @@ python main.py --config config.yml --rub-budget 10000
|
||||
python main.py --config config.yml --rub-budget 10000 --dry-run
|
||||
```
|
||||
|
||||
### Расписание и ежедневный запуск
|
||||
|
||||
Бот теперь предполагает ежедневный запуск в фиксированное время (через cron/Systemd и т.п.) и сам решает, исполнять ли сделки сегодня.
|
||||
|
||||
Правила:
|
||||
- В конфиге задаёте портфель, а день месяца указываете ключом `--day-of-month` (по умолчанию 5).
|
||||
- Если запланированный день выпадает на выходной, а флаг `--allow-weekend` не передан — сделки автоматически переносятся на ближайший понедельник.
|
||||
- Во все остальные дни бот просто завершает работу и пишет в лог статус `SKIPPED_NOT_SCHEDULED_TODAY(expected=YYYY-MM-DD)`.
|
||||
|
||||
Пример ежедневного cron на 10:00:
|
||||
|
||||
```bash
|
||||
0 10 * * * cd /path/to/dca_bot && python main.py --config config.yml --rub-budget 10000 --day-of-month 5
|
||||
```
|
||||
|
||||
Чтобы разрешить сделки в выходные именно в запланированный день:
|
||||
|
||||
```bash
|
||||
0 10 * * * cd /path/to/dca_bot && python main.py --config config.yml --rub-budget 10000 --day-of-month 5 --allow-weekend
|
||||
```
|
||||
|
||||
### Дополнительные параметры
|
||||
|
||||
```bash
|
||||
@@ -92,7 +113,8 @@ python main.py \
|
||||
--fee-buf-bps 300 \
|
||||
--safe-rub-pct 0.97 \
|
||||
--wait-tradable-sec 60 \
|
||||
--poll-sec 10
|
||||
--poll-sec 10 \
|
||||
--allow-weekend
|
||||
```
|
||||
|
||||
**Параметры:**
|
||||
@@ -103,6 +125,8 @@ python main.py \
|
||||
- `--safe-rub-pct` — использовать только N% от доступных средств (по умолчанию 0.97 = 97%)
|
||||
- `--wait-tradable-sec` — ждать до N секунд пока инструмент станет торгуемым (по умолчанию 0)
|
||||
- `--poll-sec` — интервал проверки торгового статуса в секундах (по умолчанию 10)
|
||||
- `--allow-weekend` — разрешить размещать ордера в субботу/воскресенье. По умолчанию при запуске в выходные бот фиксирует отложенные сделки в CSV со статусом `DEFERRED_TO_MONDAY(<дата>)` и завершает работу без размещения ордеров.
|
||||
- `--day-of-month` — день месяца (1..28), в который бот должен исполнять сделки. Если выпадает на выходной и не указан `--allow-weekend`, сделки переносятся на ближайший понедельник. В остальные дни бот завершает работу со статусом `SKIPPED_NOT_SCHEDULED_TODAY`.
|
||||
|
||||
## Вспомогательные инструменты
|
||||
|
||||
@@ -314,11 +338,8 @@ python main.py --config config.yml --rub-budget 10000 --dry-run
|
||||
Для регулярного запуска можно использовать cron:
|
||||
|
||||
```bash
|
||||
# Каждый понедельник в 10:00
|
||||
0 10 * * 1 cd /path/to/dca_bot && python main.py --config config.yml --rub-budget 10000
|
||||
|
||||
# Каждое 1 число месяца в 15:30
|
||||
30 15 1 * * cd /path/to/dca_bot && python main.py --config config.yml --rub-budget 50000
|
||||
# Ежедневно в 10:00 (бот сам решит исполнять ли сделки сегодня)
|
||||
0 10 * * * cd /path/to/dca_bot && python main.py --config config.yml --rub-budget 10000 --day-of-month 5
|
||||
```
|
||||
|
||||
## Структура проекта
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
@@ -9,7 +8,7 @@ import time
|
||||
import datetime as dt
|
||||
from uuid import uuid4
|
||||
from decimal import Decimal, ROUND_DOWN
|
||||
from typing import Dict, List, Optional
|
||||
from typing import Dict, List, Optional, Any
|
||||
|
||||
import yaml
|
||||
from dotenv import load_dotenv
|
||||
@@ -29,11 +28,20 @@ CSV_LOG = "orders_log.csv"
|
||||
|
||||
|
||||
# ----------------------------- utils -----------------------------
|
||||
def money_to_decimal(v: MoneyValue) -> Decimal:
|
||||
return Decimal(v.units) + (Decimal(v.nano) / Decimal(1_000_000_000))
|
||||
|
||||
|
||||
def dec(v: Quotation | MoneyValue | None, default: str = "0") -> Decimal:
|
||||
"""Безопасное преобразование Quotation/MoneyValue -> Decimal."""
|
||||
if v is None:
|
||||
return Decimal(default)
|
||||
# корректно обрабатываем как Quotation, так и MoneyValue
|
||||
if isinstance(v, Quotation):
|
||||
return quotation_to_decimal(v)
|
||||
if isinstance(v, MoneyValue):
|
||||
return money_to_decimal(v)
|
||||
return Decimal(default)
|
||||
|
||||
|
||||
def ensure_csv(path: str):
|
||||
@@ -107,7 +115,7 @@ def load_config(path: str) -> dict:
|
||||
|
||||
|
||||
# ----------------------------- market helpers -----------------------------
|
||||
def pick_account_id(client: Client, explicit: Optional[str]) -> str:
|
||||
def pick_account_id(client: Any, explicit: Optional[str]) -> str:
|
||||
"""Реальный брокерский счёт (без песочницы)."""
|
||||
if explicit:
|
||||
return explicit
|
||||
@@ -119,7 +127,7 @@ def pick_account_id(client: Client, explicit: Optional[str]) -> str:
|
||||
return accs[0].id
|
||||
|
||||
|
||||
def get_instrument_meta(client: Client, figi: str) -> dict:
|
||||
def get_instrument_meta(client: Any, figi: str) -> dict:
|
||||
"""Мини‑карточка инструмента: лот, валюта, тип, UID, и (если доступны) nominal/aci для облигаций."""
|
||||
resp = client.instruments.get_instrument_by(
|
||||
id_type=InstrumentIdType.INSTRUMENT_ID_TYPE_FIGI,
|
||||
@@ -148,7 +156,7 @@ def get_instrument_meta(client: Client, figi: str) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def fetch_last_prices(client: Client, figis: List[str]) -> Dict[str, Decimal]:
|
||||
def fetch_last_prices(client: Any, figis: List[str]) -> Dict[str, Decimal]:
|
||||
prices: Dict[str, Decimal] = {}
|
||||
resp = client.market_data.get_last_prices(figi=figis)
|
||||
for p in resp.last_prices:
|
||||
@@ -159,13 +167,11 @@ def fetch_last_prices(client: Client, figis: List[str]) -> Dict[str, Decimal]:
|
||||
return prices
|
||||
|
||||
|
||||
def get_available_rub(client: Client, account_id: str) -> Decimal:
|
||||
def get_available_rub(client: Any, account_id: str) -> Decimal:
|
||||
"""Доступные RUB = свободные - заблокированные."""
|
||||
pos = client.operations.get_positions(account_id=account_id)
|
||||
rub_money = next(
|
||||
(m for m in pos.money if m.currency.lower() == "rub"), None)
|
||||
rub_blocked = next(
|
||||
(m for m in pos.blocked if m.currency.lower() == "rub"), None)
|
||||
rub_money = next((m for m in pos.money if m.currency.lower() == "rub"), None)
|
||||
rub_blocked = next((m for m in pos.blocked if m.currency.lower() == "rub"), None)
|
||||
free_rub = dec(rub_money) if rub_money else Decimal(0)
|
||||
blocked_rub = dec(rub_blocked) if rub_blocked else Decimal(0)
|
||||
return free_rub - blocked_rub
|
||||
@@ -192,6 +198,15 @@ def main():
|
||||
required=True,
|
||||
help="Total RUB budget for this run, e.g. 10000",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--day-of-month",
|
||||
type=int,
|
||||
default=5,
|
||||
help=(
|
||||
"Day of month to execute DCA (1..28). "
|
||||
"If falls on weekend and --allow-weekend is not set, trades move to the next Monday."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--dry-run", action="store_true", help="Simulation only, no real orders"
|
||||
)
|
||||
@@ -219,6 +234,11 @@ def main():
|
||||
default=10,
|
||||
help="Polling interval for trading status while waiting",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--allow-weekend",
|
||||
action="store_true",
|
||||
help="Place orders on Saturday/Sunday as well (by default, weekend runs are deferred to next Monday)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
load_dotenv()
|
||||
@@ -232,6 +252,82 @@ def main():
|
||||
fee_buf = Decimal(args.fee_buf_bps) / Decimal(10000)
|
||||
safe_mul = Decimal(str(args.safe_rub_pct))
|
||||
|
||||
# ---------------- Daily scheduling ----------------
|
||||
# Бот запускается КАЖДЫЙ день. Он должен исполнить сделки:
|
||||
# - либо в запланированный день месяца (по умолчанию 5-е),
|
||||
# - либо в ближайший понедельник, если запланированный день выпал на выходной,
|
||||
# - иначе — завершить выполнение и зафиксировать в логах причину пропуска.
|
||||
today = dt.date.today()
|
||||
# Ограничим диапазон 1..28, чтобы не зависеть от длины месяца
|
||||
scheduled_dom = args.day_of_month
|
||||
if scheduled_dom < 1:
|
||||
scheduled_dom = 1
|
||||
if scheduled_dom > 28:
|
||||
scheduled_dom = 28
|
||||
scheduled_date = dt.date(today.year, today.month, scheduled_dom)
|
||||
|
||||
# Рассчитать эффективную дату исполнения с учётом переноса на понедельник
|
||||
if scheduled_date.weekday() >= 5 and not args.allow_weekend:
|
||||
# 5=суббота, 6=воскресенье -> перенос на ближайший понедельник
|
||||
days_to_mon = (7 - scheduled_date.weekday()) % 7
|
||||
if days_to_mon == 0:
|
||||
days_to_mon = 1
|
||||
effective_date = scheduled_date + dt.timedelta(days=days_to_mon)
|
||||
else:
|
||||
effective_date = scheduled_date
|
||||
|
||||
period_key = dt.datetime.now().strftime("%Y-%m")
|
||||
|
||||
# Если сегодня — запланированный день, но это выходной и торговать нельзя — фиксируем перенос
|
||||
if (
|
||||
today == scheduled_date
|
||||
and scheduled_date.weekday() >= 5
|
||||
and not args.allow_weekend
|
||||
):
|
||||
insert_report_separator()
|
||||
for asset in cfg["assets"]:
|
||||
append_log(
|
||||
period_key=period_key,
|
||||
figi=asset.get("figi", ""),
|
||||
ticker="",
|
||||
name=asset.get("name", ""),
|
||||
instrument_type="",
|
||||
lots=0,
|
||||
lot_size=0,
|
||||
filled_price_per_share=Decimal(0),
|
||||
cost_rub=Decimal(0),
|
||||
status=f"DEFERRED_TO_MONDAY({effective_date.isoformat()})",
|
||||
order_request_id="",
|
||||
)
|
||||
print(
|
||||
f"Scheduled day is weekend (today={today.isoformat()}). Trades deferred to Monday {effective_date.isoformat()}.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return
|
||||
|
||||
# Если сегодня не эффективная дата — просто фиксируем пропуск и выходим
|
||||
if today != effective_date:
|
||||
insert_report_separator()
|
||||
# Сводная запись (по всем инструментам) — чтобы не раздувать CSV
|
||||
append_log(
|
||||
period_key=period_key,
|
||||
figi="",
|
||||
ticker="",
|
||||
name="",
|
||||
instrument_type="",
|
||||
lots=0,
|
||||
lot_size=0,
|
||||
filled_price_per_share=Decimal(0),
|
||||
cost_rub=Decimal(0),
|
||||
status=f"SKIPPED_NOT_SCHEDULED_TODAY(expected={effective_date.isoformat()})",
|
||||
order_request_id="",
|
||||
)
|
||||
print(
|
||||
f"Not scheduled today (today={today.isoformat()}, expected={effective_date.isoformat()}).",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return
|
||||
|
||||
with Client(token) as client:
|
||||
account_id = pick_account_id(client, explicit_account)
|
||||
|
||||
@@ -287,8 +383,7 @@ def main():
|
||||
|
||||
# Полная стоимость 1 лота + буфер
|
||||
eff_lot_cost = (
|
||||
price_per_share_effective *
|
||||
Decimal(lot) * (Decimal(1) + fee_buf)
|
||||
price_per_share_effective * Decimal(lot) * (Decimal(1) + fee_buf)
|
||||
).quantize(Decimal("0.01"))
|
||||
|
||||
# Первый расчёт лотов от аллокации
|
||||
@@ -342,8 +437,10 @@ def main():
|
||||
lot_size=lot,
|
||||
filled_price_per_share=price_per_share_effective,
|
||||
cost_rub=Decimal(0),
|
||||
status=f"SKIPPED_TRADING_UNAVAILABLE(api={api_ok},mkt={mkt_ok},lim={
|
||||
lim_ok},status={tr_stat},waited_s={waited})",
|
||||
status=(
|
||||
f"SKIPPED_TRADING_UNAVAILABLE(api={api_ok},mkt={mkt_ok},"
|
||||
f"lim={lim_ok},status={tr_stat},waited_s={waited})"
|
||||
),
|
||||
order_request_id="",
|
||||
)
|
||||
continue
|
||||
@@ -406,11 +503,12 @@ def main():
|
||||
cur_lots -= 1
|
||||
attempts += 1
|
||||
continue
|
||||
if (
|
||||
e.details == "30079"
|
||||
): # Instrument is not available for trading
|
||||
status = f"SKIPPED_TRADING_UNAVAILABLE(api={api_ok},mkt={mkt_ok},lim={
|
||||
lim_ok},status={tr_stat})"
|
||||
if e.details == "30079":
|
||||
# Instrument is not available for trading
|
||||
status = (
|
||||
f"SKIPPED_TRADING_UNAVAILABLE(api={api_ok},mkt={mkt_ok},"
|
||||
f"lim={lim_ok},status={tr_stat})"
|
||||
)
|
||||
cur_lots = 0
|
||||
break
|
||||
import traceback
|
||||
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
# file: run_dca.sh
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Переходим в директорию проекта (относительно расположения скрипта)
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)"
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
# Логи
|
||||
mkdir -p ./logs
|
||||
echo "[$(date -Iseconds)] Starting DCA run" >> ./logs/dca.log
|
||||
|
||||
# Активация venv, если есть
|
||||
if [[ -d ./.venv ]]; then
|
||||
# shellcheck disable=SC1091
|
||||
source ./.venv/bin/activate
|
||||
fi
|
||||
|
||||
# Конфигурация через переменные окружения (можно переопределять из cron/systemd)
|
||||
RUB_BUDGET="${RUB_BUDGET:-5000}"
|
||||
CONFIG_FILE="${CONFIG_FILE:-config.yml}"
|
||||
DAY_OF_MONTH="${DAY_OF_MONTH:-5}" # 1..28, по умолчанию 5
|
||||
# Если ALLOW_WEEKEND непустая (например, "1"), добавим флаг --allow-weekend
|
||||
ALLOW_WEEKEND="${ALLOW_WEEKEND:-}"
|
||||
|
||||
ARGS=(
|
||||
--config "$CONFIG_FILE"
|
||||
--rub-budget "$RUB_BUDGET"
|
||||
--day-of-month "$DAY_OF_MONTH"
|
||||
)
|
||||
|
||||
if [[ -n "$ALLOW_WEEKEND" ]]; then
|
||||
ARGS+=(--allow-weekend)
|
||||
fi
|
||||
|
||||
# Запуск
|
||||
python3 main.py "${ARGS[@]}" >> ./logs/dca.log 2>&1
|
||||
Reference in New Issue
Block a user