
Содержание:
Вступление
Это вторая (и заключительная) часть цикла статей о нашей миграции с Zeppelin. О причинах и первом опыте перехода с Zeppelin я рассказал здесь. В данной статье я хочу большее внимание уделить второму виду Zeppelin notebook, которые срочно нуждались в переносе.
Конечно, отчеты для клиентов не были настолько "забагованы" как рассылки: большая часть проблем с Zeppelin крылась именно в cron-е, который временами работал как хотел (или в интерпретаторах, мы так и не смогли разобраться, но ошибка интерпретатора возникала только когда запускали через cron). В отчетах этого звена не было, поэтому их перенос был плавным и основан скорее на особенностях UI/UX дизайна.
Данная статья может быть полезна аналитикам, которые не знают, какой инструмент использовать для своих задач и думают, что писать графический интерфейс крайне сложно (спойлер, нет), а также для команд, которые устали от Zeppelin как UI-инструмента (и от Zeppelin в целом)
Вторая пачка проблем с Zeppelin
В случае отчетов проблема была, скорее, в человеческом факторе. Zeppelin может поддерживать версионность (с помощью Git). И конечно, можно подрубить удаленное хранилище для Git. Круто же, иметь бэкап данных на случай если все сломается? Только этот бэкап в итоге стрелял нам самим в ногу.
Он работал не как "резервное хранилище", которое может использоваться при потере файлов с системы, а затирал файлы при каждом перезапуске. Т. е. если ты сделал изменения в файл, успешно вышел из него (мы же все так привыкли к удобном IDE, которые сохраняют все за нас) и не нажал эту кнопку:

и не написал commit, и не нажал мышкой на отправить, то твои изменения успешно затрутся при перезапуске сервера.
Ну, как по мне это немного жестко. Особенно учитывая тот факт, что Zeppelin любит отваливаться без объяснения причин и не включаться без перезагрузки.
Финальной точкой стала проблема создания одной динамической формы на Zeppelin. Нужно было, чтобы форма записывала нужные портфели в нашу базу данных, ничего сложного. Однако из-за особенностей Zeppelin это было проблемой.
Zeppelin поддерживает Angular, и это очень круто - форму можно было сделать динамической и красивой, все круто. Frontend был готов за несколько часов (достаточно базовый, но уже рабочий)

Но ведь эту форму нужно было наполнить портфелями, а соответственно, нужен backend. Зайдя на официальную документацию видно, что backend нужно было писать на scala. А это значило:
Нужно настроить ее интерпретатор (привет, devops);
Нужно было настроить драйвера для подключения к ClickHouse (привет, devops);
Нужно было писать на scala.
Даже этот путь нам удалось пройти. В итоге форма вставки выглядела примерно следующим образом:
Пользователь заполняет необходимые значения в форме;
Все значения (после валидац��и со стороны JS) залетают в переменные Angular для того, чтобы потом забрать их бэкендом;
Все эти переменные парсятся и записываются в базу данных;
Обновляются старые переменные (хранимые в модельных портфелях для того, чтобы было возможно выбирать имя модельного портфеля из списка);
Тригерится ячейка, которая отображает таблицу (содержащая SQL запрос);
Выводится финальная таблица с результатом. Опуская некоторые блоки код выглядел так:
<!-- Код запуска формы -->
<div>
<button type="submit" id="submit-button" class="btn btn-primary" ng-click="z.angularBind('ModelPortfolioName', ModelPortfolioName, 'paragraph_1737461934941_1164301792');
z.angularBind('InstrumentName', instrument_name_hide, 'paragraph_1737461934941_1164301792');
z.angularBind('isin', isin_hide, 'paragraph_1737461934941_1164301792');
z.angularBind('Weight', Weight, 'paragraph_1737461934941_1164301792');
z.angularBind('InstrumentID', instrument_id_hide, 'paragraph_1737461934941_1164301792');
z.angularBind('ModelPortfolioName', ModelPortfolioName, 'paragraph_1737551722112_2000653657');
z.angularBind('InstrumentName', instrument_name_hide, 'paragraph_1737551722112_2000653657');
z.angularBind('isin', isin_hide, 'paragraph_1737551722112_2000653657');
z.angularBind('Weight', Weight, 'paragraph_1737551722112_2000653657');
z.angularBind('InstrumentID', instrument_id_hide, 'paragraph_1737551722112_2000653657');
z.runParagraph('paragraph_1737461934941_1164301792');
z.runParagraph('paragraph_1737551722112_2000653657');
z.runParagraph('paragraph_1737630107272_1306092962');
z.runParagraph('paragraph_1737718450269_1052487395');
z.runParagraph('paragraph_1737101842267_423222916');
z.runParagraph('paragraph_1738327466649_1082226394');
z.runParagraph('paragraph_1738323602428_1572897561');
z.runParagraph('paragraph_1737718450269_1052487395');"
onmouseover="allFieldCheck()">append</button>
</div>
<!-- Код заполнения значений инструментов -->
$(document).ready(function () {
$('#instrument_name_input').select2({
placeholder: "Check instrument name",
language: "ru",
data: window.angularVars["instrument_names"],
query: function (q) {
var pageSize = 20;
var results = [];
if (q.term && q.term !== '') {
results = _.filter(that.data, function (e) {
return e.text.toUpperCase().indexOf(q.term.toUpperCase()) >= 0;
});
} else {
results = that.data;
}
q.callback({
results: results.slice((q.page - 1) * pageSize, q.page * pageSize),
more: results.length > (q.page * pageSize)
});
},
});
// Код вставки в БД
//Select new model portfolio arr
val model_portfolios = df_model_portfolios.select("ModelPortfolioName").distinct().collect
.map(_.toSeq).flatten;
//Set new model portfolio arr
z.angularBindGlobal("model_portfolios", model_portfolios)
//Get model portfolio name from Angular (maybe this is a bad practic)
val model_portfolio_name = z.angular("ModelPortfolioName")
//Select max datetime for all inserts of portfolios
val max_datetime_by_model_portfolio = df_model_portfolios.filter("ModelPortfolioName = '"+model_portfolio_name+"'")
.groupBy("ModelPortfolioName", "InstrumentName", "ISIN", "InstrumentID")
.agg(max("InsertDatetime")).select("max(InsertDatetime)").collect
.map(_.toSeq).flatten
//Find relevant inserts
val df_filtered = df_model_portfolios.where($"InsertDatetime".isin(max_datetime_by_model_portfolio:_*)).filter("Weight!=0")
//Bind table in SQL interpretator
df_filtered.registerTempTable("model_portfolios")
И это все в данной реализации даже работало и работало хорошо. Но было одно но.
Можно увидеть, что в форме есть замечательный dropdown список, который должен заполняться значениями инструментов (для того, чтобы можно было выбрать нужный из списка). Следовательно, данный список должен был заполниться. Заполнял из backend на Scala следующим образом:
df_instruments = df_instruments.filter("TypeName not in ('MB', 'StructuralNote', 'PropertyLaw', 'Deposit', 'RealEstate') and ISIN!=''")
val instrument_names = df_instruments.filter("TypeName not in ('MB', 'StructuralNote', 'PropertyLaw', 'Deposit', 'RealEstate')")
.select("Name").collect
.map(_.toSeq).flatten;
val isins = df_instruments.filter("TypeName not in ('MB', 'StructuralNote', 'PropertyLaw', 'Deposit', 'RealEstate')")
.select("ISIN").collect
.map(_.toSeq).flatten;
val instrument_ids = df_instruments.filter("TypeName not in ('MB', 'StructuralNote', 'PropertyLaw', 'Deposit', 'RealEstate')")
.select("InstrumentID").collect
.map(_.toSeq).flatten
val addictional_instruments = Array("ОФЗ флоатер", "ОФЗ длинные", "ОФЗ короткие",
"ОФЗ ИН", "Корпоративные облигации с постоянным купоном", "Корпоративные облигации флоатеры",
"ЕвроБонд", "Замещенные облигации", "Валютные облигации", "Субфедеральные",
"Акции", "LQDT", "Золото", "Депозит", "Cash")
val instrument_names_add = addictional_instruments++instrument_names
val addictional_isins = Array("", "", "", "", "", "", "",
"", "", "", "", "", "", "", "")
val isins_add = addictional_isins++isins
val addictional_ids = Array("-1", "-2", "-3", "-4", "-5", "-6", "-7",
"-8", "-9", "-10", "-11", "-12", "-13", "-14", "-15")
val instrument_ids_add = addictional_ids++instrument_ids
z.angularBindGlobal("instrument_names", instrument_names_add);
z.angularBindGlobal("isins", isins_add);
z.angularBindGlobal("instrument_id", instrument_ids_add);
Если человек впервые заходил на страницу (т. е. еще не запускал никакие ячейки), то у него не было значения в переменной window.angularVars["instrument_names"]. А это значит, что и форма не заполнится значениями. И действительно, откуда им взяться то, если ячейка, которая их заполняет, не была запущена?
Как решить эту проблему я не придумал. Для пользователя это означало, что пока он полностью не запустит книжку форма не заполнится значениями. При этом сама форма отобразится, т. к. вывод ячейки в Zeppelin сохраняется. Возможно, можно было бы придумать что-то на запуске из при входе (что-то типа триггера JS-ом при нажатии на dropdown в форме, которая была запущена), может можно было изменить архитектуру. Но мы решили проблему на корню и отказались от Zeppelin в этой задаче)
В поисках решения проблемы
После обнаружения данных проблем стало ясно, что нужно искать фреймворк, который бы позволил нам управлять данными отчетами наиболее просто. Первой идеей было написать свое решение на Django с React-ом под капотом (у меня был опыт с таким стеком, так что это было очень даже решением). Однако от этой идеи достаточно быстро отказались:
В то время достаточно часто можно было услышать про Streamlit - хороший framework на Python, который позволял декларативно строить пользовательский интерфейс, содержал в себе как backend, так и frontend, да и писать можно было на Python, опыта на котором в команде было предостаточно. Решили начать именно с него (в целом, на нем и закончили).
На Habr статьи про него уже есть, например здесь, поэтому особенно останавливаться на его плюсах и минусах я не буду. Из интересного могу рассказать следующее:
Streamlit нативно поддерживает кэширование с использованием декоратора @st.cache_data с функцией, вывод которой нужно кэшировать;
Streamlit перерисовывает графический интерфейс только при перезапуске. Это может немного путать вначале (после использования Angular, React и прочих JS фреймворков), но со временем привыкаешь вызывать перезапуск при необходимости;
Каждая страница хранится для Streamlit в отдельном файле, что позволяет легко собирать проектную структуру, деленную на отдельные страницы. Итоговая страница собирается из частных:
menus = {
"admin": {
"Главная": [
st.Page('./page/welcome_page_admin.py', title='Приветственное окно', icon=':material/home:')
],
"Модельные портфели": [
st.Page('./page/model_portfolio_form.py', title='Изменение структуры модельного портфеля', icon=':material/add_circle:'),
st.Page('./page/model_portfolio_structure.py', title='Проверка текущей структуры портфеля', icon=':material/search_insights:'),
st.Page('./page/match_form.py', title='Заполнения связи портфелей', icon=':material/add_link:'),
st.Page('./page/match_check_form.py', title='Поиск текущих связей модельного портфеля', icon=':material/search:')
],
"Агрегированная доходность по стратегиям": [
st.Page('./page/aggregated_yields_by_strategy_form.py', title='Агрегированная доходность по стратегиям', icon=':material/attach_money:')
],
"СЧА и РСА по портфелю": [
st.Page('./page/client_report_form.py', title='СЧА и РСА по портфелю', icon=':material/attach_money:')
],
"Доходность фонда": [
st.Page('./page/funds_profit_form.py', title='Доходность фондов', icon=':material/show_chart:')
]
},
"finance": {
"Главная": [
st.Page('./page/welcome_page_finance.py', title='Приветственное окно', icon=':material/home:')
],
"Модельные портфели": [
st.Page('./page/model_portfolio_form.py', title='Изменение структуры модельного портфеля', icon=':material/add_circle:'),
st.Page('./page/model_portfolio_structure.py', title='Проверка текущей структуры портфеля', icon=':material/search_insights:'),
st.Page('./page/match_form.py', title='Заполнения связи портфелей', icon=':material/add_link:'),
st.Page('./page/match_check_form.py', title='Поиск текущих связей модельного портфеля', icon=':material/search:')
]
},
"marketing": {
"Главная": [
st.Page('./page/welcome_page_marketing.py', title='Приветственное окно', icon=':material/home:')
],
"Агрегированная доходность по стратегиям": [
st.Page('./page/aggregated_yields_by_strategy_form.py', title='Агрегированная доходность по стратегиям', icon=':material/attach_money:')
],
"СЧА и РСА по портфелю": [
st.Page('./page/client_report_form.py', title='СЧА и РСА по портфелю', icon=':material/attach_money:')
],
"Доходность фонда": [
st.Page('./page/funds_profit_form.py', title='Доходность фондов', icon=':material/show_chart:')
]
}
}
Тут видно, как мы реализовали ролевую модель для наших форм: мы просто не отрисовываем страницы для людей, не входящих в группы. Возможно, с точки зрения безопасности это не круто (можно проникнуть как-то в недоступные зоны), но так советуют на официальной странице, так что оставили так
Стоит отметить (это видно в меню), что для каждой роли у нас стоит своя главная страница. Это сделано для того, чтобы отображался нужный текст (никакого другого функционала на главной странице нет);
В Streamlit есть универсальная команда:
st.write(). И она действительно универсальная: если передаетсяpd.DataFrame, то он отрисует таблицу, если Markdown, то отрисует Markdown и так далее. Проблем с рендерингом почти нет (ну, или я не заметил);В отдельном framework-е есть поддержка LDAP-аутентификации (а у нас она в компании распространена), что позволяет делать логин из доменной учетки. Работает она хорошо, настроена у нас и позволяет не мучаться с аутентификацией.
Реализация перехода
Каждый отчет имеет свои особенности, тем не менее их объединяет одно общее правило: все они построены на выборе данных с SQL и красивом отображении их для пользователя. Следовательно, был построен примерно следующий вариант перехода:
Все SQL скрипты без изменений отправлялись в папку ./backend/sql, в которой вызываются ./backend/clickhouse.py (он импортируется в каждый нужный модуль на каждой странице);
Страница вызывает нужную функцию из этого модуля и отрисовывает данные, полученные из базы данных и предоставляет их таким образом пользователю;
Если нужно, то в том же модуле есть функции, которые определяют вставку данных в БД (например, для модельных портфелей). И все! Это очень сильно упростило создание всех отчетов) Изначально я потратил на отчет по модельным портфелем (с созданием страницы, запросов, коннекторов и прочим) 1.5 недели. Переписывая его на Streamlit я, без преувеличения, потратил 1 рабочий день. Время на разработку сократилось в несколько раз! Стоит разобрать одну страницу для того, чтобы понять, как удалось добиться такой скорости разработки.
import streamlit as st
import time
from backend.clickhouse import SQLConn #коннектор для БД
@st.cache_data #декоратор для кэширования данных
def load_data():
sql = SQLConn()
model_portfolio = sql.get_model_portfolios_arr() #модельные портфели
instrument_id, isin, instrument_name = sql.get_instruments() #инструменты
return model_portfolio, instrument_id, instrument_name, isin
model_portfolio, instrument_id, instrument_name, isin = load_data() #с этого момента данные закэшированы и не обновляются
# Момент инициализации переменных в среде состояния
if 'selected_instrument_name' not in st.session_state:
st.session_state.selected_instrument_name = instrument_name[0]
if 'selected_isin' not in st.session_state:
st.session_state.selected_isin = isin[0]
if 'selected_weight' not in st.session_state:
st.session_state.selected_weight = '100'
if 'error_msg' not in st.session_state:
st.session_state.error_msg = ''
if 'form_state' not in st.session_state:
st.session_state.form_state='Создание нового портфеля'
if 'model_portfolio_structure' not in st.session_state:
st.session_state.model_portfolio_structure=''
if 'model_portfolio' not in st.session_state:
st.session_state.model_portfolio=''
#...
#дополнительная логика страницы
#...
# Отрисовка страницы
col1, col2 = st.columns([0.3, 0.7])
with col1:
st.radio("Выбор формы:",
options=['Создание нового портфеля',
'Наполнение существующего портфеля'],
key='form_state')
with col2:
if st.session_state.form_state == 'Создание нового портфеля':
st.text_input("Имя модельного портфеля", key='model_portfolio')
elif st.session_state.form_state == 'Наполнение существующего портфеля':
model_portfolio_select = st.selectbox("Имя модельного портфеля",
model_portfolio, key='model_portfolio')
st.selectbox("Наименование инструмента",
instrument_name, on_change=instrument_name_change, key='selected_instrument_name')
st.selectbox("ISIN инструмента",
isin, on_change=isin_change, key='selected_isin')
st.text_input("Вес инструмента", key='selected_weight', on_change=weight_change)
st.write(st.session_state.error_msg)
st.write(st.session_state.model_portfolio_structure)
if st.button('Добавить'):
sql = SQLConn()
sql.insert_model_portfolio(ModelPortfolioName=st.session_state.model_portfolio,
InstrumentName=st.session_state.selected_instrument_name,
InstrumentID=instrument_id[instrument_name.index(st.session_state.selected_instrument_name)],
ISIN=st.session_state.selected_isin,
Weight=st.session_state.selected_weight,
InsertDatetime=int(time.time()))
structure = sql.get_model_portfolio(st.session_state.model_portfolio)
structure.columns=['ModelPortfolioName', 'InstrumentName', 'ISIN', 'Type', 'Weight']
st.session_state.model_portfolio_structure=structure
if structure['Weight'].sum()>100:
st.session_state.error_msg='Сумма весов превышает 100 процентов, отредактируйте веса'
elif structure['Weight'].sum()<100:
st.session_state.error_msg='Сумма весов меньше 100 процентов, отредактируйте веса'
else:
st.session_state.error_msg=''
st.cache_data.clear() #очистка кэша (для обновления модельных портфелей)
st.rerun()
В результате получается вот такая страница:

Для чуть менее 100 строчек кода это, как по мне, очень хороший результат. В настоящий момент все новые отчеты появляются именно на данном фреймворке благодаря плюсам, которые были перечислены выше.
Таким образом получилось, что Streamlit у нас переехал в отдельный контейнер, на котором сейчас и развивается. Из одной маленькой формы он превратился в большую площадку, на которые заходят сотрудники компании для своих задач. А вместе с LDAP аутентификацией он позволил сделать ролевую модель, которая также была важна.
Заключение
Конечно, переход на Streamlit был спровоцирован простым принципом: чем проще, тем лучше. Да и к тому же если бы мы захотели писать на Angular, то лучше делать это в окружении, которое свободно от других зависимостей и не мучать Zeppelin сложными формами и логикой.
Таким образом, внедрение данного решения привело к следующему:
Что получили от реализации | Что потеряли от реализации |
|---|---|
Сокращение времени разработки с 1-2 недель до одного рабочего дня | Меньше гибкости во frontend: теперь весь frontend у нас представляет собой стандартизированный вид |
Вся разработка ведется теперь на Python, а это именно тот язык, который лучше всего знает команда | Streamlit перерисовывает страницу целиком, что уступает в динамическом контроле DOM-дерева |
Простая архитектура для красивых отчетов | Ограниченная ролевая безопасность для пользователей |
Однако стоит отметить, что основной потребитель наших отчетов: это именно внутренний пользователь. У него есть не много запросов: отчет должен отрисоватся правильно и быстро. Именно эту потребность закрывает Streamlit: если что-то нужно добавить, то скорость в разы быстрее. Если что-то сломалось, то можно быстро понять, где именно, изолировать это и починить
В итоге от данного внедрения мы получили главный результат: увеличение скорости разработки (действительно, что еще нужно разработчику). Отчеты на связке нескольких языков ушли в прошлое и это позволяет больше сосредоточится на алгоритмах расчетов, а не на отрисовке результатов.
Коротко наш опыт можно описать так: если вам нужна кастомизация интерфейса, то Streamlit - не лучший выбор. Но если вам нужен фреймворк, который дешевый в разработке и с которым справится любой разработчик на Python, то это ваш выбор!
Надеюсь, что эта статья была полезна для тех, кто ее прочитал и, возможно, еще раз продемонстрировала сообществу замечательный инструмент (или открыла его новые возможности)!
