diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..8a12fac
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,2 @@
+**/*.db
+**/logs
\ No newline at end of file
diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..7dd18cb
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,2 @@
+BOT_TOKEN=your_telegram_bot_token
+DATABASE_NAME=database
\ No newline at end of file
diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml
index 9b37780..c5bdc65 100644
--- a/.github/workflows/python-app.yml
+++ b/.github/workflows/python-app.yml
@@ -12,20 +12,41 @@ permissions:
jobs:
black-test:
runs-on: ubuntu-latest
- container: python:3.11
+ container: python:3.13-alpine
steps:
- uses: actions/checkout@v3
- name: Install dependencies
run: pip install black
- name: Check code formatting with black
- run: black ./ --check --verbose --diff
+ run: black ./tfinance --check --verbose --diff
flake8-test:
runs-on: ubuntu-latest
- container: python:3.11
+ container: python:3.13-alpine
steps:
- uses: actions/checkout@v3
- name: Install dependencies
run: pip install -r requirements/test.txt
- name: Check code formatting with flake8
- run: flake8 ./
\ No newline at end of file
+ run: flake8 ./tfinance
+
+ prod-deploy:
+ if: github.ref == 'refs/heads/main'
+ needs: [ black-test, flake8-test ]
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v3
+
+ - name: Deploy to server
+ uses: appleboy/ssh-action@master
+ with:
+ host: ${{ secrets.SERVER_HOST }}
+ username: ${{ secrets.SERVER_USERNAME }}
+ key: ${{ secrets.SERVER_SSH_KEY }}
+ script: |
+ cd ~/TFinance
+ git reset --hard origin/main
+ git pull origin main
+ docker compose down -v
+ docker compose up --build -d
diff --git a/.gitignore b/.gitignore
index 4d257f8..732e2c7 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,5 +1,6 @@
/.idea
-/logs
-/data.db
-/safety_key.py
-stocks.json
\ No newline at end of file
+logs
+sqlite
+*.db
+stocks.json
+.env
\ No newline at end of file
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..6c25353
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,10 @@
+FROM python:3.13-alpine
+
+COPY ./requirements /requirements
+RUN pip install --no-cache-dir -r requirements/prod.txt
+RUN rm -rf requirements
+
+COPY ./tfinance /tfinance/
+WORKDIR /tfinance
+
+ENTRYPOINT ["python", "main.py"]
\ No newline at end of file
diff --git a/README.md b/README.md
index 6eb4225..1b3d400 100644
--- a/README.md
+++ b/README.md
@@ -5,8 +5,8 @@
Проект: Telegram bot TFinace
TFinance - телеграм бот, позволяющий получить доступ к курсу
-акций. На понравившиеся вам акции вы можете подписаться и включить ежедненую
-рассылку их курса. Также вы можете попытать удачу и сыграть в игру, предугодав
+акций. На понравившиеся вам акции вы можете подписаться и включить ежедневную
+рассылку их курса. Также вы можете попытать удачу и сыграть в игру, предугадав
будущий курс, в случае удачи вы заработаете 1 очко в криптоигре.
Задачи, которые мы выполнили:
@@ -23,7 +23,7 @@ TFinance - телеграм бот, позволяющий получить до
Использовали команду /unfollow для отписки от акции.
Использовали команду /stock для вывода курса акции.
Использовали команду /stocks для вывода акций с биржи.
- Использовали команду /daily для подписки на ежедненую расслылку курсов
+ Использовали команду /daily для подписки на ежедневную рассылку курсов
избранных акций.
Получение списка акций с биржи с помощью http-запросов.
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..3ce0109
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,10 @@
+services:
+ tfinance_bot:
+ build: .
+ container_name: tfinance_bot
+ env_file:
+ - ./.env
+ restart: unless-stopped
+ volumes:
+ - ./etc_tfinance/logs:/tfinance/logs
+ - ./etc_tfinance/sqlite:/tfinance/sqlite
diff --git a/pyproject.toml b/pyproject.toml
index 276515c..7bdbcf3 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,19 +1,12 @@
[tool.flake8]
-max-line-length = 89
+max-line-length = 79
inline-quotes="double"
import-order-style="google"
-extend-ignore = [
- "R503",
- "R502",
- "F401",
- "N802",
-]
exclude = [
".git",
"__pycache__",
- "docs/source/conf.py",
"venv",
]
[tool.black]
-line-length = 89
\ No newline at end of file
+line-length = 79
\ No newline at end of file
diff --git a/requirements.txt b/requirements.txt
index 1525017..04a9c77 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,6 +1,6 @@
matplotlib>=3.5.3
+python-dotenv>=1.0.1
python-telegram-bot>=21.1.1
python-telegram-bot[job-queue]
-pytz>=2024.1
requests>=2.31.0
yfinance>=0.2.38
\ No newline at end of file
diff --git a/requirements/prod.txt b/requirements/prod.txt
index 1525017..04a9c77 100644
--- a/requirements/prod.txt
+++ b/requirements/prod.txt
@@ -1,6 +1,6 @@
matplotlib>=3.5.3
+python-dotenv>=1.0.1
python-telegram-bot>=21.1.1
python-telegram-bot[job-queue]
-pytz>=2024.1
requests>=2.31.0
yfinance>=0.2.38
\ No newline at end of file
diff --git a/requirements/test.txt b/requirements/test.txt
index 25ee9fe..85a115b 100644
--- a/requirements/test.txt
+++ b/requirements/test.txt
@@ -1,6 +1,10 @@
flake8>=6.1.0
+flake8-all-not-strings>=0.0.2
flake8-commas>=2.1.0
flake8-comments>=0.1.2
+flake8-dunder-all>=0.4.1
+flake8-encodings>=0.5.1
+flake8-eradicate>=1.5.0
flake8-print>=5.0.0
Flake8-pyproject>=1.2.3
flake8-quotes>=3.3.2
diff --git a/blast.py b/tfinance/blast.py
similarity index 90%
rename from blast.py
rename to tfinance/blast.py
index 14db945..42a1cbd 100644
--- a/blast.py
+++ b/tfinance/blast.py
@@ -11,7 +11,8 @@
async def notify_assignees(context: CallbackContext):
db = Database()
- # Перебираем всех пользователей и рассылаем каждому курсы их избранных акций.
+ # Перебираем всех пользователей и
+ # рассылаем каждому курсы их избранных акций.
for user in db.get_users():
if db.check_user_daily_notify(user):
if user.favourite_stocks:
@@ -35,3 +36,9 @@ async def daily(update: Update, _):
else:
await update.message.reply_text("Ежедневная рассылка включена")
db.user_daily_notify(user)
+
+
+__all__ = [
+ "daily",
+ "notify_assignees",
+]
diff --git a/tfinance/config.py b/tfinance/config.py
new file mode 100644
index 0000000..da9b843
--- /dev/null
+++ b/tfinance/config.py
@@ -0,0 +1,12 @@
+import os
+from zoneinfo import ZoneInfo
+
+from dotenv import load_dotenv
+
+load_dotenv()
+
+
+BOT_TOKEN = os.getenv("BOT_TOKEN")
+DATABASE_NAME = os.getenv("DATABASE_NAME")
+
+TIMEZONE = ZoneInfo("Europe/Moscow")
diff --git a/database.py b/tfinance/database.py
similarity index 93%
rename from database.py
rename to tfinance/database.py
index e3c9998..ac22a7a 100644
--- a/database.py
+++ b/tfinance/database.py
@@ -1,6 +1,8 @@
import sqlite3
+from pathlib import Path
from models import User
+from config import DATABASE_NAME
def singleton(cls):
@@ -22,7 +24,11 @@ class Database:
def __init__(self):
# Подключение к БД с отключенной проверкой потока.
- self.con = sqlite3.connect("data.db", check_same_thread=False)
+ Path("sqlite").mkdir(exist_ok=True)
+ self.con = sqlite3.connect(
+ f"sqlite/{DATABASE_NAME}.db",
+ check_same_thread=False,
+ )
self.cur = self.con.cursor()
self.setup()
@@ -111,7 +117,8 @@ def add_prediction(self, user: User, stock_name: str, updown: str):
if predictions[0]:
prediction = f"{predictions[0]} {prediction}"
self.cur.execute(
- f"UPDATE users SET prediction = ' {prediction}' WHERE id = {user.id}",
+ f"UPDATE users SET prediction = ' {prediction}' "
+ f"WHERE id = {user.id}",
)
self.con.commit()
@@ -145,7 +152,9 @@ def delete_predictions(self, user: User):
:param user: Экземпляр класса User с данными об этом пользователе.
:return: None
"""
- self.cur.execute(f"UPDATE users SET prediction = '' WHERE id = {user.id}")
+ self.cur.execute(
+ f"UPDATE users SET prediction = '' WHERE id = {user.id}",
+ )
self.con.commit()
def select_stock(self, user: User, stock_name: str):
@@ -161,11 +170,12 @@ def select_stock(self, user: User, stock_name: str):
if selected_stocks:
stock_name = f"{selected_stocks} {stock_name}"
self.cur.execute(
- f"UPDATE users SET selected_stock = '{stock_name}' WHERE id = {user.id}",
+ f"UPDATE users SET selected_stock = '{stock_name}' "
+ f"WHERE id = {user.id}",
)
self.con.commit()
- def get_selected_stock_byid(self, user: User, message_id) -> str:
+ def get_selected_stock_byid(self, user: User, message_id) -> str | None:
"""
Получить название акции по id сообщения с игрой.
:param user: Экземпляр класса User с данными об этом пользователе.
@@ -179,6 +189,7 @@ def get_selected_stock_byid(self, user: User, message_id) -> str:
for i in data.split():
if str(message_id) == i.split(":")[-1]:
return i.split(":")[0]
+ return None
def get_selected_stocks(self, user: User) -> list:
"""
@@ -253,7 +264,8 @@ def add_favourites_stocks(self, user: User, stock_name: str):
if stocks and stocks[0]:
stock_name = f"{stocks[0]} {stock_name}"
self.cur.execute(
- f"UPDATE users SET favourites_stocks = '{stock_name}' WHERE id = {user.id}",
+ f"UPDATE users SET favourites_stocks = '{stock_name}' "
+ f"WHERE id = {user.id}",
)
self.con.commit()
@@ -299,7 +311,8 @@ def remove_favourites_stock(self, user: User, stock_name: str):
else:
a = f"'{' '.join(a)}'"
self.cur.execute(
- f"UPDATE users SET favourites_stocks = {a} WHERE id = {user.id}",
+ f"UPDATE users SET favourites_stocks = {a} "
+ f"WHERE id = {user.id}",
)
self.con.commit()
@@ -310,7 +323,8 @@ def user_daily_notify(self, user: User):
:return: None
"""
self.cur.execute(
- f"UPDATE users SET daily_notify = NOT daily_notify WHERE id = {user.id}",
+ f"UPDATE users SET daily_notify = NOT daily_notify "
+ f"WHERE id = {user.id}",
)
self.con.commit()
@@ -339,3 +353,8 @@ def add_point(self, user: User):
)
user.points += 1
self.con.commit()
+
+
+__all__ = [
+ "Database",
+]
diff --git a/exceptions.py b/tfinance/exceptions.py
similarity index 78%
rename from exceptions.py
rename to tfinance/exceptions.py
index 186abcc..74ae891 100644
--- a/exceptions.py
+++ b/tfinance/exceptions.py
@@ -14,3 +14,11 @@ class EmptyDataFrameError(Exception): # Пустой дата фрейм
class WrongPeriodError(Exception): # Неверный период
pass
+
+
+__all__ = [
+ "StockSelectedAlready",
+ "PredictionAlreadySet",
+ "EmptyDataFrameError",
+ "WrongPeriodError",
+]
diff --git a/functions.py b/tfinance/functions.py
similarity index 91%
rename from functions.py
rename to tfinance/functions.py
index cc77e8a..b5b751c 100644
--- a/functions.py
+++ b/tfinance/functions.py
@@ -13,3 +13,8 @@ def create_user(update: Update) -> User:
user_data["language_code"],
user_data["is_bot"],
)
+
+
+__all__ = [
+ "create_user",
+]
diff --git a/game.py b/tfinance/game.py
similarity index 89%
rename from game.py
rename to tfinance/game.py
index 926e9d5..c683c93 100644
--- a/game.py
+++ b/tfinance/game.py
@@ -1,8 +1,22 @@
-from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Update
-from telegram.ext import ContextTypes, CallbackContext
+from telegram import (
+ InlineKeyboardButton,
+ InlineKeyboardMarkup,
+ Update,
+)
+from telegram.ext import (
+ ContextTypes,
+ CallbackContext,
+ ConversationHandler,
+ CommandHandler,
+ CallbackQueryHandler,
+)
from database import Database
-from exceptions import EmptyDataFrameError, PredictionAlreadySet, StockSelectedAlready
+from exceptions import (
+ EmptyDataFrameError,
+ PredictionAlreadySet,
+ StockSelectedAlready,
+)
from functions import create_user
from graphics.visualize import check_stock_prices, do_stock_image
from models import User
@@ -17,12 +31,14 @@ async def game_menu(update: Update, context: ContextTypes.DEFAULT_TYPE):
# Сохраняем id сообщения для возможности одновременной
message_id = str(int(update.message.message_id) + 2)
# Игры на многих акциях.
- # Прибавляем 2 т.к. отправляем 2 сообщения: фото и приписку к нему с клавиатурой.
+ # Прибавляем 2 т.к. отправляем 2 сообщения:
+ # фото и приписку к нему с клавиатурой.
try:
# Проверка на наличие аргументов.
if not context.args:
await update.message.reply_text(
- "Неправильно введена команда! Попробуйте: /game [индекс акции]",
+ "Неправильно введена команда! "
+ "Попробуйте: /game [индекс акции]",
)
# Проверка: была ли выбрана акция до этого? Избегаем читерства.
if db.check_selected_stocks(user):
@@ -80,7 +96,8 @@ async def game_menu(update: Update, context: ContextTypes.DEFAULT_TYPE):
)
db.remove_selected_stock(user, message_id)
- # Возвращаем 1, чтобы показать ConversationHandler'у состояние, в котором находимся.
+ # Возвращаем 1, чтобы показать ConversationHandler'у состояние,
+ # в котором находимся.
return 1
@@ -194,3 +211,21 @@ async def game_results(context: CallbackContext):
# Удаляем пройденные прогнозы
db.delete_predictions(user)
user.prediction = db.get_predictions(user)
+
+
+game_handler = ConversationHandler(
+ entry_points=[CommandHandler("game", game_menu)],
+ states={
+ 1: [
+ CallbackQueryHandler(higher_game, pattern="^1$"),
+ CallbackQueryHandler(lower_game, pattern="^2$"),
+ ],
+ },
+ fallbacks=[CommandHandler("game", game_menu)],
+)
+
+
+__all__ = [
+ "game_handler",
+ "game_results",
+]
diff --git a/graphics/visualize.py b/tfinance/graphics/visualize.py
similarity index 89%
rename from graphics/visualize.py
rename to tfinance/graphics/visualize.py
index d76e988..dc87888 100644
--- a/graphics/visualize.py
+++ b/tfinance/graphics/visualize.py
@@ -5,7 +5,7 @@
from exceptions import EmptyDataFrameError, WrongPeriodError
-time_periods = {
+TIME_PERIODS = {
"1d": "за 1 день",
"5d": "за 5 дней",
"1mo": "за 1 месяц",
@@ -27,7 +27,7 @@ def do_stock_image(stock_name, period="1mo"):
:param period: 1d,5d,1mo,3mo,6mo,1y,2y,5y,10y,ytd,max
:return: Картинка в байтовом формате.
"""
- if period not in time_periods:
+ if period not in TIME_PERIODS:
raise WrongPeriodError
# Забираем данные из Yahoo Finance.
stock = yf.download(stock_name, period=period)
@@ -38,7 +38,10 @@ def do_stock_image(stock_name, period="1mo"):
# Создаем полотно с графиком.
stock["Close"].plot(grid=True)
- plt.title(f"Курс акции {stock_name} {time_periods.get(period)}", fontsize=14)
+ plt.title(
+ f"Курс акции {stock_name} {TIME_PERIODS.get(period)}",
+ fontsize=14,
+ )
plt.gca().set(ylabel="Price USD")
# Записываем полученный график в байты и возвращаем полученное изображение.
@@ -60,3 +63,9 @@ def check_stock_prices(stock_name) -> bool:
curr_price = stock["Close"][-1]
return curr_price > prev_price
+
+
+__all__ = [
+ "check_stock_prices",
+ "do_stock_image",
+]
diff --git a/main.py b/tfinance/main.py
similarity index 82%
rename from main.py
rename to tfinance/main.py
index d2c7f03..71219f8 100644
--- a/main.py
+++ b/tfinance/main.py
@@ -1,20 +1,15 @@
-# -------------------------- Основной файл приложения -------------------------- #
-# --------------- Импорт необходимых библиотек, функций, классов --------------- #
+# ------------------------ Основной файл приложения ------------------------ #
+# ------------- Импорт необходимых библиотек, функций, классов ------------- #
# Встроенные библиотеки.
import datetime
import logging
-import os
from pathlib import Path
-import warnings
-import pytz
from telegram import Update
# Работа с telegram-bot-api.
from telegram.ext import (
- CallbackQueryHandler,
CommandHandler,
- ConversationHandler,
Application,
ContextTypes,
)
@@ -26,26 +21,31 @@
from database import Database
from exceptions import EmptyDataFrameError, WrongPeriodError
from functions import create_user
-from game import game_menu, game_results, higher_game, lower_game
+from game import game_handler, game_results
from graphics.visualize import do_stock_image
-from safety_key import TOKEN
from stock import check_stock, get_all_stocks, load_stocks
+from config import BOT_TOKEN, TIMEZONE
+__all__ = []
# Запускаем логирование
-logs_path = Path(f"{os.getcwd()}/logs")
-if not logs_path.exists():
- logs_path.mkdir(exist_ok=True)
+Path("logs").mkdir(exist_ok=True)
logging.basicConfig(
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
- level=logging.WARNING,
- filename=f"{logs_path}/tfinance_main.log",
+ level=logging.INFO,
+ handlers=[
+ logging.FileHandler(
+ datetime.datetime.now(
+ tz=TIMEZONE,
+ ).strftime("logs/%Y-%m-%d_%H-%M-%S.log"),
+ encoding="utf-8",
+ ),
+ logging.StreamHandler(),
+ ],
)
+logging.getLogger("httpx").setLevel(logging.WARNING)
logger = logging.getLogger(__name__)
-# Отключаем предупреждения пользователей библиотек
-warnings.simplefilter("ignore")
-
# Получение списка необходимых акций по команде /stocks [args].
# Обработчик команды /stocks.
@@ -86,7 +86,8 @@ async def get_stock_image(update: Update, context: ContextTypes.DEFAULT_TYPE):
)
except WrongPeriodError:
await update.message.reply_text(
- "Неверный период. Доступные периоды: 1d,5d,1mo,3mo,6mo,1y,2y,5y,10y,ytd,max",
+ "Неверный период. "
+ "Доступные периоды: 1d,5d,1mo,3mo,6mo,1y,2y,5y,10y,ytd,max",
)
except EmptyDataFrameError:
await update.message.reply_text(
@@ -152,7 +153,8 @@ async def follow(update: Update, context: ContextTypes.DEFAULT_TYPE):
await update.message.reply_text("Акция не найдена")
else:
await update.message.reply_text(
- "Неверный способ ввода. /follow [индекс акции]. Например: /follow AAPL",
+ "Неверный способ ввода. "
+ "/follow [индекс акции]. Например: /follow AAPL",
)
@@ -191,7 +193,6 @@ async def stats(update: Update, _):
)
-# Основной цикл, активирующийся при запуске.
def main():
# Получение и сохранение списка всех акций в stocks.json.
try:
@@ -199,39 +200,26 @@ def main():
except Exception as e:
logging.error(e)
- application = Application.builder().token(TOKEN).build()
+ application = Application.builder().token(BOT_TOKEN).build()
job_queue = application.job_queue
- # Ежедневные задачи.
job_queue.run_daily(
notify_assignees,
datetime.time(
hour=8,
- tzinfo=pytz.timezone("Europe/Moscow"),
+ tzinfo=TIMEZONE,
),
)
job_queue.run_daily(
game_results,
datetime.time(
hour=3,
- tzinfo=pytz.timezone("Europe/Moscow"),
+ tzinfo=TIMEZONE,
),
)
- # Обработчик для игры.
- game_handler = ConversationHandler(
- entry_points=[CommandHandler("game", game_menu)],
- states={
- 1: [
- CallbackQueryHandler(higher_game, pattern="^1$"),
- CallbackQueryHandler(lower_game, pattern="^2$"),
- ],
- },
- fallbacks=[CommandHandler("game", game_menu)],
- )
application.add_handler(game_handler)
- # Регистрируем обработчик команд.
application.add_handler(CommandHandler("daily", daily))
application.add_handler(CommandHandler("start", start))
application.add_handler(CommandHandler("help", help_msg))
@@ -242,7 +230,6 @@ def main():
application.add_handler(CommandHandler("stocks", get_list_stocks))
application.add_handler(CommandHandler("stats", stats))
- # Обработка сообщений.
application.run_polling(allowed_updates=Update.ALL_TYPES)
diff --git a/models.py b/tfinance/models.py
similarity index 96%
rename from models.py
rename to tfinance/models.py
index a60b081..1fb3ef0 100644
--- a/models.py
+++ b/tfinance/models.py
@@ -21,3 +21,8 @@ def __init__(
self.prediction = prediction
self.selected_stock = selected_stock
self.points: int = 0
+
+
+__all__ = [
+ "User",
+]
diff --git a/stock.py b/tfinance/stock.py
similarity index 79%
rename from stock.py
rename to tfinance/stock.py
index 8124627..b6ddc9f 100644
--- a/stock.py
+++ b/tfinance/stock.py
@@ -14,7 +14,7 @@
# Загрузка списка всех акций.
def load_stocks(file_name: str) -> list[dict[str, str]]:
- path = Path(f"{Path.cwd()}/{file_name}")
+ path = Path(f"{file_name}")
if path.exists():
with path.open() as f:
return json.load(f)
@@ -25,8 +25,7 @@ def load_stocks(file_name: str) -> list[dict[str, str]]:
def check_stock(stock_name: str) -> bool:
try:
stock = yf.download(stock_name, period="1mo")
- if stock["Close"][-2]:
- return True
+ return bool(stock["Close"][-2])
except Exception as e:
logging.exception(e)
return False
@@ -34,7 +33,7 @@ def check_stock(stock_name: str) -> bool:
# Сохранение списка акций в json.
def save_stocks(file_name: str, stocks: list):
- with Path(f"{Path.cwd()}/{file_name}").open("w") as f:
+ with Path(file_name).open("w") as f:
json.dump(stocks, f)
@@ -54,5 +53,18 @@ def get_all_stocks():
.get("table")
.get("rows")
)
- stocks = [{"symbol": i.get("symbol"), "name": i.get("name")} for i in stocks]
+ stocks = [
+ {
+ "symbol": i.get("symbol"),
+ "name": i.get("name"),
+ }
+ for i in stocks
+ ]
save_stocks("stocks.json", stocks)
+
+
+__all__ = [
+ "check_stock",
+ "get_all_stocks",
+ "load_stocks",
+]