From 940d25435ee56b95ba5704fcc01f3025aadebd69 Mon Sep 17 00:00:00 2001 From: oqyude Date: Sun, 12 Jul 2026 19:46:07 +0300 Subject: [PATCH] bootstrap --- .agent/analysis-report.md | 56 +++++++++ .agent/checkpoints.json | 24 ++++ .agent/design-report.md | 151 ++++++++++++++++++++++++ .agent/handoff-summary.md | 106 +++++++++++++++++ .agent/task-manifest.json | 178 +++++++++++++++++++++++++++++ .agent/task-manifest.md | 209 ++++++++++++++++++++++++++++++++++ ai/__init__.py | 4 + ai/assistant.py | 56 +++++++++ ai/prompts.py | 46 ++++++++ cashflow_model/__init__.py | 17 +++ cashflow_model/account.py | 27 +++++ cashflow_model/asset.py | 27 +++++ cashflow_model/liability.py | 30 +++++ cashflow_model/model.py | 54 +++++++++ cashflow_model/recurring.py | 33 ++++++ cashflow_model/scenario.py | 33 ++++++ cashflow_model/transaction.py | 33 ++++++ cli/__init__.py | 0 cli/main.py | 201 ++++++++++++++++++++++++++++++++ data/.gitkeep | 0 data/model.json | 8 ++ engine/__init__.py | 10 ++ engine/forecast.py | 100 ++++++++++++++++ engine/scenarios.py | 87 ++++++++++++++ exports/.gitkeep | 0 pyproject.toml | 30 +++++ sync/__init__.py | 3 + sync/excel_sync.py | 108 ++++++++++++++++++ tests/__init__.py | 0 tests/conftest.py | 56 +++++++++ tests/test_ai.py | 24 ++++ tests/test_cli.py | 25 ++++ tests/test_excel_sync.py | 39 +++++++ tests/test_forecast.py | 36 ++++++ tests/test_model.py | 82 +++++++++++++ tests/test_scenarios.py | 21 ++++ 36 files changed, 1914 insertions(+) create mode 100644 .agent/analysis-report.md create mode 100644 .agent/checkpoints.json create mode 100644 .agent/design-report.md create mode 100644 .agent/handoff-summary.md create mode 100644 .agent/task-manifest.json create mode 100644 .agent/task-manifest.md create mode 100644 ai/__init__.py create mode 100644 ai/assistant.py create mode 100644 ai/prompts.py create mode 100644 cashflow_model/__init__.py create mode 100644 cashflow_model/account.py create mode 100644 cashflow_model/asset.py create mode 100644 cashflow_model/liability.py create mode 100644 cashflow_model/model.py create mode 100644 cashflow_model/recurring.py create mode 100644 cashflow_model/scenario.py create mode 100644 cashflow_model/transaction.py create mode 100644 cli/__init__.py create mode 100644 cli/main.py create mode 100644 data/.gitkeep create mode 100644 data/model.json create mode 100644 engine/__init__.py create mode 100644 engine/forecast.py create mode 100644 engine/scenarios.py create mode 100644 exports/.gitkeep create mode 100644 pyproject.toml create mode 100644 sync/__init__.py create mode 100644 sync/excel_sync.py create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/test_ai.py create mode 100644 tests/test_cli.py create mode 100644 tests/test_excel_sync.py create mode 100644 tests/test_forecast.py create mode 100644 tests/test_model.py create mode 100644 tests/test_scenarios.py diff --git a/.agent/analysis-report.md b/.agent/analysis-report.md new file mode 100644 index 0000000..0541b63 --- /dev/null +++ b/.agent/analysis-report.md @@ -0,0 +1,56 @@ +# Analysis Report + +## Session + +- **Session ID:** `metaagent-001` +- **Target repo:** `S:\Git\nifodea` +- **Date:** 2026-07-12 +- **Project type:** `greenfield` + +## 1. Общая информация + +- **README:** CashFlow Forecast — open-source проект для построения и анализа личной финансовой модели с использованием Spreadsheet, Python и AI. +- **Лицензия:** не указана +- **CI/CD:** отсутствует +- **Точка входа:** не определена +- **Система сборки:** отсутствует + +## 7. Требования (из README) + +### Функциональные требования + +- Прогноз денежных потоков (Cash Flow Forecasting) +- Моделирование бюджета +- Сценарный анализ (What-if Analysis) +- Учет активов и обязательств +- Прогноз ликвидности +- Анализ финансовой устойчивости +- Моделирование достижения финансовых целей +- Импорт/экспорт данных через Spreadsheet (Excel) + +### Нефункциональные требования + +- **Spreadsheet** — визуальный редактор и UI +- **Python** — вычислительное ядро +- **JSON** — внутреннее представление модели данных +- **AI** — инструмент анализа и взаимодействия +- Все компоненты должны быть взаимозаменяемыми +- Open-source + +### Бизнес-контекст + +- **Цель:** отвечать на вопрос "Что произойдет дальше?" (forecast), а не "Что произошло?" (accounting) +- **Аудитория:** частные лица для персонального финансового планирования +- **Успех:** работающая прогнозная модель денежных потоков с возможностью сценарного анализа + +### Неясные моменты / Вопросы + +- Не указана лицензия — требуется уточнить +- Не указана конкретная версия Python +- Какой формат Spreadsheet (Excel .xlsx, Google Sheets, оба)? +- Какие AI-провайдеры предполагаются (OpenAI, локальные модели)? +- Требуется ли веб-интерфейс или только CLI + Spreadsheet? + +## 8. Примечания + +Проект greenfield. MVP должен включать: модель данных (JSON), ядро forecast engine, базовый sync с Excel, интерфейс для AI-ассистента. Все модули — Python. diff --git a/.agent/checkpoints.json b/.agent/checkpoints.json new file mode 100644 index 0000000..21f4da1 --- /dev/null +++ b/.agent/checkpoints.json @@ -0,0 +1,24 @@ +{ + "session_id": "metaagent-001", + "target_repo": "S:\\Git\\nifodea", + "goal": "Спроектируй и реализуй MVP по README", + "project_type": "greenfield", + "phases": { + "analysis": "completed", + "design": "completed", + "decomposition": "completed", + "environment": "completed", + "handoff": "completed" + }, + "tasks": [ + {"id": "T1", "title": "Инициализация проекта и зависимостей", "status": "pending", "depends_on": [], "acceptance_criteria": ["pyproject.toml создан", "Все __init__.py созданы", "ruff проходит", "pytest запускается"]}, + {"id": "T2", "title": "Модель данных (dataclass + JSON)", "status": "pending", "depends_on": ["T1"], "acceptance_criteria": ["Все сущности dataclass", "FinancialModel save/load JSON", "demo-скрипт работает"]}, + {"id": "T3", "title": "Forecast Engine", "status": "pending", "depends_on": ["T2"], "acceptance_criteria": ["forecast_cashflow работает", "recurring проецируются", "активы/обязательства учтены"]}, + {"id": "T4", "title": "Scenario Analysis", "status": "pending", "depends_on": ["T3"], "acceptance_criteria": ["3 сценария", "what-if модификация", "сравнение сценариев"]}, + {"id": "T5", "title": "Excel Sync", "status": "pending", "depends_on": ["T2"], "acceptance_criteria": ["импорт из Excel", "экспорт в Excel", "ошибки невалидного формата"]}, + {"id": "T6", "title": "CLI (Typer)", "status": "pending", "depends_on": ["T2","T3","T4","T5","T7"], "acceptance_criteria": ["init/forecast/analyze/import/export/scenario команды"]}, + {"id": "T7", "title": "AI Assistant", "status": "pending", "depends_on": ["T3"], "acceptance_criteria": ["промпты с моделью и прогнозом", "заглушка ответа"]}, + {"id": "T8", "title": "Тесты", "status": "pending", "depends_on": ["T2","T3","T4","T5","T6","T7"], "acceptance_criteria": ["pytest проходит", "покрытие всех модулей"]} + ], + "last_updated": "2026-07-12T19:36:00Z" +} diff --git a/.agent/design-report.md b/.agent/design-report.md new file mode 100644 index 0000000..f1f94b0 --- /dev/null +++ b/.agent/design-report.md @@ -0,0 +1,151 @@ +# Design Report + +## Session + +- **Session ID:** `metaagent-001` +- **Target repo:** `S:\Git\nifodea` +- **Date:** 2026-07-12 + +## 1. Технологический стек + +| Компонент | Выбор | Обоснование | +|---|---|---| +| Язык | Python 3.11+ | Указан в README как вычислительное ядро; широкая экосистема для работы с данными | +| Фреймворк | Typer (CLI), openpyxl (Excel) | Typer — современный CLI-фреймворк; openpyxl — стандарт для .xlsx | +| База данных | JSON-файлы | README требует JSON как внутреннее представление; для MVP БД не нужна | +| Инфраструктура | pip + venv | Минимальная зависимость; .gitignore уже настроен под Python | +| Линтер | ruff | Стандарт для Python 2024+; быстрый, уже в .gitignore | +| Тесты | pytest | Стандартный тестовый раннер для Python | +| AI | Интерфейс через промпты | Для MVP — только промпты и абстракция, без подключения к API | + +## 2. High-Level архитектура + +**Паттерн:** Модульный монолит (Layered) + +``` +[CLI / Excel File] + | + ▼ + sync/ ──► cashflow_model/ ──► engine/ ──► ai/ + (Excel R/W) (Entity Model) (Forecast) (Prompts) + | | | + ▼ ▼ ▼ + data/model.json data/model.json data/model.json +``` + +**Поток данных:** +1. Пользователь редактирует Excel → sync читает и преобразует в JSON +2. JSON-модель загружается в Python-объекты (dataclass) +3. Forecast Engine вычисляет прогноз на основе модели +4. AI Assistant анализирует результаты через промпты +5. Результаты экспортируются обратно в Excel + +## 3. Модули + +| Модуль | Ответственность | Ключевые компоненты | Зависит от | +|---|---|---|---| +| `cashflow_model/` | Определение сущностей (dataclass), сериализация/десериализация JSON | `Account`, `Transaction`, `RecurringCashflow`, `Asset`, `Liability`, `ForecastScenario`, `FinancialModel` | — | +| `sync/` | Чтение и запись Excel (.xlsx), конвертация между Excel и JSON | `excel_sync.py` — импорт/экспорт | `cashflow_model` | +| `engine/` | Расчёт прогноза, сценарный анализ, what-if | `forecast.py` (прогноз), `scenarios.py` (сценарии) | `cashflow_model` | +| `ai/` | Промпты для AI-ассистента, форматирование контекста | `prompts.py` (шаблоны), `assistant.py` (интерфейс) | `cashflow_model`, `engine` | +| `cli/` | CLI-интерфейс (Typer) | `main.py` — точки входа | Все модули | + +## 4. Модели данных + +### Account + +| Поле | Тип | Ограничения | Описание | +|---|---|---|---| +| id | UUID | pk | Уникальный идентификатор | +| name | str | required | Название счёта | +| currency | str | default="USD" | Валюта | +| balance | float | required | Текущий баланс | + +### Transaction + +| Поле | Тип | Ограничения | Описание | +|---|---|---|---| +| id | UUID | pk | Уникальный идентификатор | +| date | str (ISO date) | required | Дата операции | +| account | UUID | fk → Account | Счёт | +| category | str | required | Категория | +| amount | float | required | Сумма | +| description | str | optional | Описание | + +### RecurringCashflow + +| Поле | Тип | Ограничения | Описание | +|---|---|---|---| +| id | UUID | pk | Уникальный идентификатор | +| start_date | str (ISO date) | required | Дата начала | +| end_date | str (ISO date) | optional | Дата окончания | +| frequency | str | enum: monthly/weekly/yearly | Периодичность | +| amount | float | required | Сумма | +| category | str | required | Категория | + +### Asset + +| Поле | Тип | Ограничения | Описание | +|---|---|---|---| +| id | UUID | pk | Уникальный идентификатор | +| name | str | required | Название | +| value | float | required | Текущая стоимость | +| growth_rate | float | default=0.0 | Годовой темп роста (%) | + +### Liability + +| Поле | Тип | Ограничения | Описание | +|---|---|---|---| +| id | UUID | pk | Уникальный идентификатор | +| name | str | required | Название | +| balance | float | required | Текущий остаток | +| interest | float | required | Годовая ставка (%) | +| payment | float | required | Ежемесячный платёж | + +**Связи:** +- Transaction → Account (многие к одному) +- RecurringCashflow → Account (многие к одному, опционально) +- FinancialModel включает все сущности + параметры + +## 5. API / Интерфейсы + +### CLI (Typer) + +| Команда | Описание | Пример | +|---|---|---| +| `import ` | Импорт данных из Excel в JSON | `cf import data.xlsx` | +| `export ` | Экспорт из JSON в Excel | `cf export report.xlsx` | +| `forecast [--months 12]` | Запуск прогноза | `cf forecast --months 12` | +| `scenario ` | Применить сценарий | `cf scenario optimistic` | +| `analyze` | AI-анализ модели | `cf analyze` | +| `init` | Инициализация пустой модели | `cf init` | + +## 6. Обработка ошибок + +- **Стратегия:** Исключения Python с кастомными типами (`ModelError`, `SyncError`, `ForecastError`) +- **Формат ошибок:** `{ "error": "", "code": "", "details": {} }` +- **Логирование:** logging с уровнями INFO/ERROR; CLI-вывод через Typer + rich + +## 7. Тестирование + +- **Unit-тесты:** pytest для каждого модуля (cashflow_model, engine, sync) +- **Integration-тесты:** чтение/запись Excel, полный цикл import → forecast → export +- **Mock-стратегия:** временные файлы для Excel/JSON тестов +- **Команда запуска:** `pytest` + +## 8. Предварительная группировка задач + +| Задача | Описание | Тип | +|---|---|---| +| T1 | Инициализация проекта + scaffold | config | +| T2 | Модель данных (dataclass + JSON serialization) | feature | +| T3 | Forecast Engine (базовый прогноз) | feature | +| T4 | Excel Sync (import/export) | feature | +| T5 | CLI (Typer) — все команды | feature | +| T6 | AI Assistant (промпты + интерфейс) | feature | +| T7 | Тесты на все модули | test | +| T8 | Финальная проверка и документация | docs | + +## 9. Примечания + +Для MVP берётся минимальный функционал: модель + forecast + excel sync + cli. AI — только интерфейс (заглушка с промптами). Сценарии — базовая реализация. diff --git a/.agent/handoff-summary.md b/.agent/handoff-summary.md new file mode 100644 index 0000000..80e5a06 --- /dev/null +++ b/.agent/handoff-summary.md @@ -0,0 +1,106 @@ +# Handoff Summary + +## Session Info + +- **Session ID:** `metaagent-001` +- **Target Repo:** `S:\Git\nifodea` +- **Goal:** Спроектируй и реализуй MVP по README +- **Date:** 2026-07-12 +- **Duration:** ~1 session + +## Repo Summary + +Проект CashFlow Forecast — личная финансовая модель с прогнозом денежных потоков. Greenfield. Python + JSON + Excel + AI интерфейс. + +## Project Type + +- **Type:** greenfield +- **Design report:** `.agent/design-report.md` + +## Environment Status + +- **Build:** OK (pip install -e . — success) +- **Tests:** 0/0 passed (greenfield, scaffold ready) +- **Baseline log:** `.agent/baseline-test-report.log` +- **Dependencies:** openpyxl, typer, rich, pytest, ruff + +## Task Overview + +| Status | Count | +|---|---| +| Total | 8 | +| Pending | 8 | +| In Progress | 0 | +| Completed | 0 | +| Failed/Skipped | 0 | + +**Task by type:** +- config: 1 +- feature: 6 +- test: 1 + +## Tasks (ordered) + +### T1: Инициализация проекта и зависимостей +- Type: config +- Depends on: — +- Files: pyproject.toml, cashflow_model/__init__.py, sync/__init__.py, engine/__init__.py, ai/__init__.py, cli/__init__.py, data/.gitkeep, exports/.gitkeep +- **Status: completed** (done in SETUP phase) + +### T2: Модель данных (dataclass + JSON serialization) +- Type: feature +- Depends on: T1 +- Files: cashflow_model/*.py +- Status: pending + +### T3: Forecast Engine (базовый прогноз) +- Type: feature +- Depends on: T2 +- Files: engine/forecast.py +- Status: pending + +### T4: Scenario Analysis +- Type: feature +- Depends on: T3 +- Files: engine/scenarios.py +- Status: pending + +### T5: Excel Sync (import/export) +- Type: feature +- Depends on: T2 +- Files: sync/excel_sync.py +- Status: pending + +### T6: CLI (Typer) — все команды +- Type: feature +- Depends on: T2, T3, T4, T5, T7 +- Files: cli/main.py +- Status: pending + +### T7: AI Assistant (промпты + интерфейс) +- Type: feature +- Depends on: T3 +- Files: ai/prompts.py, ai/assistant.py +- Status: pending + +### T8: Тесты на все модули +- Type: test +- Depends on: T2, T3, T4, T5, T6, T7 +- Files: tests/*.py +- Status: pending + +## Next Steps + +Исполнительный агент начинает с задачи **T1** (уже выполнена в SETUP), затем **T2: Модель данных**. + +## Caveats + +- AI Assistant — заглушка для MVP; промпты готовы, но не подключены к реальному API +- Лицензия не указана — требуется решить +- Версия Python — 3.11+ (фактически 3.13 в окружении) +- Spreadsheet — только .xlsx через openpyxl + +## Checkpoints + +Файл: `.agent/checkpoints.json` +Актуальное состояние чекпоинтов прилагается. diff --git a/.agent/task-manifest.json b/.agent/task-manifest.json new file mode 100644 index 0000000..55517e0 --- /dev/null +++ b/.agent/task-manifest.json @@ -0,0 +1,178 @@ +{ + "$schema": "metaagent-task-manifest", + "version": "1.0", + "session_id": "metaagent-001", + "goal": "Спроектируй и реализуй MVP по README", + "created_at": "2026-07-12T12:00:00Z", + "tasks": [ + { + "id": "T1", + "title": "Инициализация проекта и зависимостей", + "description": "Создать структуру директорий, pyproject.toml, venv, установить зависимости (openpyxl, typer, pytest, ruff), настроить ruff", + "type": "config", + "files": [ + "pyproject.toml", + "cashflow_model/__init__.py", + "sync/__init__.py", + "engine/__init__.py", + "ai/__init__.py", + "cli/__init__.py", + "data/.gitkeep", + "exports/.gitkeep" + ], + "depends_on": [], + "acceptance_criteria": [ + "pyproject.toml создан с правильными зависимостями", + "Все директории модулей созданы с __init__.py", + "ruff lint проходит без ошибок на пустых модулях", + "pytest запускается (0 tests, exit code 0)" + ], + "context": "Стек: Python 3.11+, openpyxl, typer, pytest, ruff. Структура из design-report.md раздел 3.", + "status": "pending" + }, + { + "id": "T2", + "title": "Модель данных (dataclass + JSON serialization)", + "description": "Реализовать все сущности: Account, Transaction, RecurringCashflow, Asset, Liability, ForecastScenario, FinancialModel. Каждая — dataclass с методами to_dict/from_dict. FinancialModel — корневой объект с методами save/load JSON.", + "type": "feature", + "files": [ + "cashflow_model/__init__.py", + "cashflow_model/account.py", + "cashflow_model/transaction.py", + "cashflow_model/recurring.py", + "cashflow_model/asset.py", + "cashflow_model/liability.py", + "cashflow_model/scenario.py", + "cashflow_model/model.py" + ], + "depends_on": ["T1"], + "acceptance_criteria": [ + "Все сущности — dataclass с правильными полями и типами", + "FinancialModel корректно сохраняется и загружается из JSON", + "Создание Account, Transaction, Asset, Liability через конструктор работает", + "demo-скрипт создаёт модель с тестовыми данными" + ], + "context": "Модели определены в design-report.md раздел 4. Использовать uuid4 для id.", + "status": "pending" + }, + { + "id": "T3", + "title": "Forecast Engine (базовый прогноз)", + "description": "Реализовать ForecastService с методами: forecast_cashflow (на N месяцев), apply_recurring (генерация recurring-транзакций), project_balance. Алгоритм: начальный баланс + доходы - расходы + изменения по активам/обязательствам.", + "type": "feature", + "files": [ + "engine/__init__.py", + "engine/forecast.py" + ], + "depends_on": ["T2"], + "acceptance_criteria": [ + "forecast_cashflow(months=12) возвращает список помесячных балансов", + "Регулярные платежи корректно проецируются на будущие периоды", + "Активы учитываются с ростом (growth_rate)", + "Обязательства учитываются с процентами и платежами" + ], + "context": "Расчёт: balance_{t+1} = balance_t + income_t - expense_t + asset_growth_t - liability_change_t. См. design-report раздел 2.", + "status": "pending" + }, + { + "id": "T4", + "title": "Scenario Analysis", + "description": "Реализовать ScenarioService с методами: сценарии (optimistic, pessimistic, baseline), what-if модификация параметров, сравнение результатов сценариев.", + "type": "feature", + "files": [ + "engine/__init__.py", + "engine/scenarios.py" + ], + "depends_on": ["T3"], + "acceptance_criteria": [ + "Три предустановленных сценария (baseline, optimistic, pessimistic)", + "What-if: изменение параметров (доход +10%, расход -5%)", + "Сравнение сценариев возвращает сводку различий" + ], + "context": "Сценарии меняют параметры модели перед прогнозом. Использовать copy модели для каждого сценария.", + "status": "pending" + }, + { + "id": "T5", + "title": "Excel Sync (import/export)", + "description": "Реализовать ExcelSync: чтение модели из .xlsx (листы: Accounts, Transactions, Assets, Liabilities, Recurring), запись результатов прогноза в новый .xlsx. Использовать openpyxl.", + "type": "feature", + "files": [ + "sync/__init__.py", + "sync/excel_sync.py" + ], + "depends_on": ["T2"], + "acceptance_criteria": [ + "Импорт из Excel заполняет FinancialModel", + "Экспорт FinancialModel в Excel создаёт корректный .xlsx", + "Обработка ошибок при невалидном формате Excel" + ], + "context": "Каждая сущность — отдельный лист. Заголовки колонок = поля dataclass. См. design-report раздел 5.", + "status": "pending" + }, + { + "id": "T6", + "title": "CLI (Typer) — все команды", + "description": "Реализовать CLI через Typer с командами: init, import, export, forecast, scenario, analyze. Главный entry point — консольная команда 'cf'.", + "type": "feature", + "files": [ + "cli/__init__.py", + "cli/main.py", + "pyproject.toml" + ], + "depends_on": ["T2", "T3", "T4", "T5", "T7"], + "acceptance_criteria": [ + "Команда 'cf init' создаёт пустую модель и JSON", + "Команда 'cf forecast --months 12' выводит таблицу прогноза", + "Команда 'cf analyze' вызывает AI Assistant", + "Команда 'cf import' и 'cf export' работают с Excel", + "Команда 'cf scenario' применяет и выводит сценарий" + ], + "context": "Typer entry point. Команды описаны в design-report раздел 5. rich для форматирования таблиц.", + "status": "pending" + }, + { + "id": "T7", + "title": "AI Assistant (промпты + интерфейс)", + "description": "Реализовать AssistantService: генерация промптов для AI на основе модели и прогноза, форматирование контекста (JSON-дамп модели + результаты forecast), заглушка для вызова AI API. Промпты на русском языке для анализа фин. состояния.", + "type": "feature", + "files": [ + "ai/__init__.py", + "ai/prompts.py", + "ai/assistant.py" + ], + "depends_on": ["T3"], + "acceptance_criteria": [ + "Промпт 'analyze' включает модель и прогноз в JSON", + "Промпт 'advice' формирует запрос на финансовые рекомендации", + "AssistantService возвращает структурированный ответ (заглушка)" + ], + "context": "AI — заглушка для MVP. Промпты должны быть готовы для реального API. См. design-report раздел 7.", + "status": "pending" + }, + { + "id": "T8", + "title": "Тесты на все модули", + "description": "Написать pytest-тесты для cashflow_model (сериализация), engine (forecast + scenarios), sync (excel roundtrip), ai (prompts), cli (invocation).", + "type": "test", + "files": [ + "tests/test_model.py", + "tests/test_forecast.py", + "tests/test_scenarios.py", + "tests/test_excel_sync.py", + "tests/test_cli.py", + "tests/test_ai.py", + "tests/conftest.py" + ], + "depends_on": ["T2", "T3", "T4", "T5", "T6", "T7"], + "acceptance_criteria": [ + "pytest запускается и все тесты проходят", + "Покрытие базовых сценариев для каждой сущности", + "Roundtrip-тест Excel: export → import → compare", + "Forecast-тест: известные входные данные → ожидаемый результат" + ], + "context": "Использовать tmp_path для временных файлов. conftest.py с fixture для тестовой модели.", + "status": "pending" + } + ] +} diff --git a/.agent/task-manifest.md b/.agent/task-manifest.md new file mode 100644 index 0000000..8378364 --- /dev/null +++ b/.agent/task-manifest.md @@ -0,0 +1,209 @@ +# Task Manifest + +**Session:** metaagent-001 +**Goal:** Спроектируй и реализуй MVP по README +**Date:** 2026-07-12T12:00:00Z + +--- + +## Task Overview + +| ID | Title | Type | Depends On | Status | +|---|---|---|---|---| +| T1 | Инициализация проекта и зависимостей | config | — | pending | +| T2 | Модель данных (dataclass + JSON serialization) | feature | T1 | pending | +| T3 | Forecast Engine (базовый прогноз) | feature | T2 | pending | +| T4 | Scenario Analysis | feature | T3 | pending | +| T5 | Excel Sync (import/export) | feature | T2 | pending | +| T6 | CLI (Typer) — все команды | feature | T2, T3, T4, T5, T7 | pending | +| T7 | AI Assistant (промпты + интерфейс) | feature | T3 | pending | +| T8 | Тесты на все модули | test | T2, T3, T4, T5, T6, T7 | pending | + +**Total tasks:** 8 + +--- + +## Task Details + +### T1: Инициализация проекта и зависимостей + +**Type:** config +**Description:** Создать структуру директорий, pyproject.toml, venv, установить зависимости (openpyxl, typer, pytest, ruff), настроить ruff + +**Files:** +- `pyproject.toml` +- `cashflow_model/__init__.py` +- `sync/__init__.py` +- `engine/__init__.py` +- `ai/__init__.py` +- `cli/__init__.py` +- `data/.gitkeep` +- `exports/.gitkeep` + +**Depends on:** — + +**Acceptance Criteria:** +- [ ] pyproject.toml создан с правильными зависимостями +- [ ] Все директории модулей созданы с __init__.py +- [ ] ruff lint проходит без ошибок на пустых модулях +- [ ] pytest запускается (0 tests, exit code 0) + +**Context:** Стек: Python 3.11+, openpyxl, typer, pytest, ruff. Структура из design-report.md раздел 3. + +--- + +### T2: Модель данных (dataclass + JSON serialization) + +**Type:** feature +**Description:** Реализовать все сущности: Account, Transaction, RecurringCashflow, Asset, Liability, ForecastScenario, FinancialModel. Каждая — dataclass с методами to_dict/from_dict. FinancialModel — корневой объект с методами save/load JSON. + +**Files:** +- `cashflow_model/__init__.py` +- `cashflow_model/account.py` +- `cashflow_model/transaction.py` +- `cashflow_model/recurring.py` +- `cashflow_model/asset.py` +- `cashflow_model/liability.py` +- `cashflow_model/scenario.py` +- `cashflow_model/model.py` + +**Depends on:** T1 + +**Acceptance Criteria:** +- [ ] Все сущности — dataclass с правильными полями и типами +- [ ] FinancialModel корректно сохраняется и загружается из JSON +- [ ] Создание Account, Transaction, Asset, Liability через конструктор работает +- [ ] demo-скрипт создаёт модель с тестовыми данными + +**Context:** Модели определены в design-report.md раздел 4. Использовать uuid4 для id. + +--- + +### T3: Forecast Engine (базовый прогноз) + +**Type:** feature +**Description:** Реализовать ForecastService с методами: forecast_cashflow (на N месяцев), apply_recurring (генерация recurring-транзакций), project_balance. Алгоритм: начальный баланс + доходы - расходы + изменения по активам/обязательствам. + +**Files:** +- `engine/__init__.py` +- `engine/forecast.py` + +**Depends on:** T2 + +**Acceptance Criteria:** +- [ ] forecast_cashflow(months=12) возвращает список помесячных балансов +- [ ] Регулярные платежи корректно проецируются на будущие периоды +- [ ] Активы учитываются с ростом (growth_rate) +- [ ] Обязательства учитываются с процентами и платежами + +**Context:** Расчёт: balance_{t+1} = balance_t + income_t - expense_t + asset_growth_t - liability_change_t. + +--- + +### T4: Scenario Analysis + +**Type:** feature +**Description:** Реализовать ScenarioService с методами: сценарии (optimistic, pessimistic, baseline), what-if модификация параметров, сравнение результатов сценариев. + +**Files:** +- `engine/__init__.py` +- `engine/scenarios.py` + +**Depends on:** T3 + +**Acceptance Criteria:** +- [ ] Три предустановленных сценария (baseline, optimistic, pessimistic) +- [ ] What-if: изменение параметров (доход +10%, расход -5%) +- [ ] Сравнение сценариев возвращает сводку различий + +**Context:** Сценарии меняют параметры модели перед прогнозом. Использовать copy модели для каждого сценария. + +--- + +### T5: Excel Sync (import/export) + +**Type:** feature +**Description:** Реализовать ExcelSync: чтение модели из .xlsx (листы: Accounts, Transactions, Assets, Liabilities, Recurring), запись результатов прогноза в новый .xlsx. Использовать openpyxl. + +**Files:** +- `sync/__init__.py` +- `sync/excel_sync.py` + +**Depends on:** T2 + +**Acceptance Criteria:** +- [ ] Импорт из Excel заполняет FinancialModel +- [ ] Экспорт FinancialModel в Excel создаёт корректный .xlsx +- [ ] Обработка ошибок при невалидном формате Excel + +**Context:** Каждая сущность — отдельный лист. Заголовки колонок = поля dataclass. + +--- + +### T6: CLI (Typer) — все команды + +**Type:** feature +**Description:** Реализовать CLI через Typer с командами: init, import, export, forecast, scenario, analyze. Главный entry point — консольная команда 'cf'. + +**Files:** +- `cli/__init__.py` +- `cli/main.py` +- `pyproject.toml` + +**Depends on:** T2, T3, T4, T5, T7 + +**Acceptance Criteria:** +- [ ] Команда 'cf init' создаёт пустую модель и JSON +- [ ] Команда 'cf forecast --months 12' выводит таблицу прогноза +- [ ] Команда 'cf analyze' вызывает AI Assistant +- [ ] Команда 'cf import' и 'cf export' работают с Excel +- [ ] Команда 'cf scenario' применяет и выводит сценарий + +**Context:** Typer entry point. Команды описаны в design-report раздел 5. rich для форматирования таблиц. + +--- + +### T7: AI Assistant (промпты + интерфейс) + +**Type:** feature +**Description:** Реализовать AssistantService: генерация промптов для AI на основе модели и прогноза, форматирование контекста (JSON-дамп модели + результаты forecast), заглушка для вызова AI API. Промпты на русском языке для анализа фин. состояния. + +**Files:** +- `ai/__init__.py` +- `ai/prompts.py` +- `ai/assistant.py` + +**Depends on:** T3 + +**Acceptance Criteria:** +- [ ] Промпт 'analyze' включает модель и прогноз в JSON +- [ ] Промпт 'advice' формирует запрос на финансовые рекомендации +- [ ] AssistantService возвращает структурированный ответ (заглушка) + +**Context:** AI — заглушка для MVP. Промпты должны быть готовы для реального API. + +--- + +### T8: Тесты на все модули + +**Type:** test +**Description:** Написать pytest-тесты для cashflow_model (сериализация), engine (forecast + scenarios), sync (excel roundtrip), ai (prompts), cli (invocation). + +**Files:** +- `tests/test_model.py` +- `tests/test_forecast.py` +- `tests/test_scenarios.py` +- `tests/test_excel_sync.py` +- `tests/test_cli.py` +- `tests/test_ai.py` +- `tests/conftest.py` + +**Depends on:** T2, T3, T4, T5, T6, T7 + +**Acceptance Criteria:** +- [ ] pytest запускается и все тесты проходят +- [ ] Покрытие базовых сценариев для каждой сущности +- [ ] Roundtrip-тест Excel: export → import → compare +- [ ] Forecast-тест: известные входные данные → ожидаемый результат + +**Context:** Использовать tmp_path для временных файлов. conftest.py с fixture для тестовой модели. diff --git a/ai/__init__.py b/ai/__init__.py new file mode 100644 index 0000000..9fd77b2 --- /dev/null +++ b/ai/__init__.py @@ -0,0 +1,4 @@ +from ai import prompts +from ai.assistant import AssistantError, AssistantService + +__all__ = ["AssistantService", "AssistantError", "prompts"] diff --git a/ai/assistant.py b/ai/assistant.py new file mode 100644 index 0000000..f21ee74 --- /dev/null +++ b/ai/assistant.py @@ -0,0 +1,56 @@ +import json + +from ai import prompts +from cashflow_model import FinancialModel +from engine.forecast import ForecastService + + +class AssistantError(Exception): + pass + + +class AssistantService: + def __init__(self, model: FinancialModel): + self.model = model + + def analyze(self, months: int = 12) -> dict: + forecast_service = ForecastService(self.model) + forecast_result = forecast_service.forecast_cashflow(months) + summary = forecast_service.summary(months) + + prompt = prompts.format_context( + model_json=json.dumps(self.model.to_dict(), indent=2, ensure_ascii=False), + forecast_json=json.dumps(forecast_result, indent=2, ensure_ascii=False), + months=months, + ) + + return { + "prompt": prompt, + "summary": summary, + "forecast": forecast_result, + "ai_response": None, + } + + def advice(self, question: str, months: int = 12) -> dict: + forecast_service = ForecastService(self.model) + forecast_result = forecast_service.forecast_cashflow(months) + + prompt = prompts.ADVICE_PROMPT.format( + model_json=json.dumps(self.model.to_dict(), indent=2, ensure_ascii=False), + forecast_json=json.dumps(forecast_result, indent=2, ensure_ascii=False), + question=question, + ) + + return { + "prompt": prompt, + "ai_response": None, + } + + def compare_scenarios(self, scenarios_json: str) -> dict: + prompt = prompts.SCENARIO_COMPARISON_PROMPT.format( + scenarios_json=scenarios_json, + ) + return { + "prompt": prompt, + "ai_response": None, + } diff --git a/ai/prompts.py b/ai/prompts.py new file mode 100644 index 0000000..7734abe --- /dev/null +++ b/ai/prompts.py @@ -0,0 +1,46 @@ +ANALYZE_PROMPT = """ +Ты — финансовый AI-ассистент. Проанализируй финансовую модель пользователя. + +### Модель (JSON): +{model_json} + +### Прогноз на {months} месяцев: +{forecast_json} + +Дай анализ по пунктам: +1. Общее финансовое состояние +2. Тренд денежного потока (рост/падение) +3. Достаточность ликвидности +4. Рекомендации по улучшению +""" + +ADVICE_PROMPT = """ +Ты — финансовый AI-ассистент. Дай персональные рекомендации. + +### Модель: +{model_json} + +### Прогноз: +{forecast_json} + +Вопрос пользователя: {question} + +Ответь как опытный финансовый консультант. +""" + +SCENARIO_COMPARISON_PROMPT = """ +Ты — финансовый AI-ассистент. Сравни сценарии прогноза. + +### Результаты сценариев: +{scenarios_json} + +Дай рекомендацию: какой сценарий наиболее вероятен и почему. +""" + + +def format_context(model_json: str, forecast_json: str, months: int = 12) -> str: + return ANALYZE_PROMPT.format( + model_json=model_json, + forecast_json=forecast_json, + months=months, + ) diff --git a/cashflow_model/__init__.py b/cashflow_model/__init__.py new file mode 100644 index 0000000..89c7fb4 --- /dev/null +++ b/cashflow_model/__init__.py @@ -0,0 +1,17 @@ +from cashflow_model.account import Account +from cashflow_model.asset import Asset +from cashflow_model.liability import Liability +from cashflow_model.model import FinancialModel +from cashflow_model.recurring import RecurringCashflow +from cashflow_model.scenario import ForecastScenario +from cashflow_model.transaction import Transaction + +__all__ = [ + "Account", + "Transaction", + "RecurringCashflow", + "Asset", + "Liability", + "ForecastScenario", + "FinancialModel", +] diff --git a/cashflow_model/account.py b/cashflow_model/account.py new file mode 100644 index 0000000..f70ca25 --- /dev/null +++ b/cashflow_model/account.py @@ -0,0 +1,27 @@ +from dataclasses import dataclass, field +from uuid import UUID, uuid4 + + +@dataclass +class Account: + id: UUID = field(default_factory=uuid4) + name: str = "" + currency: str = "USD" + balance: float = 0.0 + + def to_dict(self) -> dict: + return { + "id": str(self.id), + "name": self.name, + "currency": self.currency, + "balance": self.balance, + } + + @classmethod + def from_dict(cls, data: dict) -> "Account": + return cls( + id=UUID(data["id"]), + name=data["name"], + currency=data.get("currency", "USD"), + balance=data.get("balance", 0.0), + ) diff --git a/cashflow_model/asset.py b/cashflow_model/asset.py new file mode 100644 index 0000000..e68f670 --- /dev/null +++ b/cashflow_model/asset.py @@ -0,0 +1,27 @@ +from dataclasses import dataclass, field +from uuid import UUID, uuid4 + + +@dataclass +class Asset: + id: UUID = field(default_factory=uuid4) + name: str = "" + value: float = 0.0 + growth_rate: float = 0.0 + + def to_dict(self) -> dict: + return { + "id": str(self.id), + "name": self.name, + "value": self.value, + "growth_rate": self.growth_rate, + } + + @classmethod + def from_dict(cls, data: dict) -> "Asset": + return cls( + id=UUID(data["id"]), + name=data["name"], + value=data.get("value", 0.0), + growth_rate=data.get("growth_rate", 0.0), + ) diff --git a/cashflow_model/liability.py b/cashflow_model/liability.py new file mode 100644 index 0000000..bdbaa01 --- /dev/null +++ b/cashflow_model/liability.py @@ -0,0 +1,30 @@ +from dataclasses import dataclass, field +from uuid import UUID, uuid4 + + +@dataclass +class Liability: + id: UUID = field(default_factory=uuid4) + name: str = "" + balance: float = 0.0 + interest: float = 0.0 + payment: float = 0.0 + + def to_dict(self) -> dict: + return { + "id": str(self.id), + "name": self.name, + "balance": self.balance, + "interest": self.interest, + "payment": self.payment, + } + + @classmethod + def from_dict(cls, data: dict) -> "Liability": + return cls( + id=UUID(data["id"]), + name=data["name"], + balance=data.get("balance", 0.0), + interest=data.get("interest", 0.0), + payment=data.get("payment", 0.0), + ) diff --git a/cashflow_model/model.py b/cashflow_model/model.py new file mode 100644 index 0000000..109e366 --- /dev/null +++ b/cashflow_model/model.py @@ -0,0 +1,54 @@ +import json +from dataclasses import dataclass, field +from pathlib import Path + +from cashflow_model.account import Account +from cashflow_model.asset import Asset +from cashflow_model.liability import Liability +from cashflow_model.recurring import RecurringCashflow +from cashflow_model.scenario import ForecastScenario +from cashflow_model.transaction import Transaction + + +@dataclass +class FinancialModel: + accounts: list[Account] = field(default_factory=list) + transactions: list[Transaction] = field(default_factory=list) + recurring: list[RecurringCashflow] = field(default_factory=list) + assets: list[Asset] = field(default_factory=list) + liabilities: list[Liability] = field(default_factory=list) + scenarios: list[ForecastScenario] = field(default_factory=list) + + def to_dict(self) -> dict: + return { + "accounts": [a.to_dict() for a in self.accounts], + "transactions": [t.to_dict() for t in self.transactions], + "recurring": [r.to_dict() for r in self.recurring], + "assets": [a.to_dict() for a in self.assets], + "liabilities": [li.to_dict() for li in self.liabilities], + "scenarios": [s.to_dict() for s in self.scenarios], + } + + @classmethod + def from_dict(cls, data: dict) -> "FinancialModel": + return cls( + accounts=[Account.from_dict(a) for a in data.get("accounts", [])], + transactions=[Transaction.from_dict(t) for t in data.get("transactions", [])], + recurring=[RecurringCashflow.from_dict(r) for r in data.get("recurring", [])], + assets=[Asset.from_dict(a) for a in data.get("assets", [])], + liabilities=[Liability.from_dict(li) for li in data.get("liabilities", [])], + scenarios=[ForecastScenario.from_dict(s) for s in data.get("scenarios", [])], + ) + + def save(self, path: str | Path) -> None: + path = Path(path) + path.parent.mkdir(parents=True, exist_ok=True) + with open(path, "w", encoding="utf-8") as f: + json.dump(self.to_dict(), f, indent=2, ensure_ascii=False) + + @classmethod + def load(cls, path: str | Path) -> "FinancialModel": + path = Path(path) + with open(path, "r", encoding="utf-8") as f: + data = json.load(f) + return cls.from_dict(data) diff --git a/cashflow_model/recurring.py b/cashflow_model/recurring.py new file mode 100644 index 0000000..6ca69f4 --- /dev/null +++ b/cashflow_model/recurring.py @@ -0,0 +1,33 @@ +from dataclasses import dataclass, field +from uuid import UUID, uuid4 + + +@dataclass +class RecurringCashflow: + id: UUID = field(default_factory=uuid4) + start_date: str = "" + end_date: str = "" + frequency: str = "monthly" + amount: float = 0.0 + category: str = "" + + def to_dict(self) -> dict: + return { + "id": str(self.id), + "start_date": self.start_date, + "end_date": self.end_date, + "frequency": self.frequency, + "amount": self.amount, + "category": self.category, + } + + @classmethod + def from_dict(cls, data: dict) -> "RecurringCashflow": + return cls( + id=UUID(data["id"]), + start_date=data.get("start_date", ""), + end_date=data.get("end_date", ""), + frequency=data.get("frequency", "monthly"), + amount=data.get("amount", 0.0), + category=data.get("category", ""), + ) diff --git a/cashflow_model/scenario.py b/cashflow_model/scenario.py new file mode 100644 index 0000000..349c5ba --- /dev/null +++ b/cashflow_model/scenario.py @@ -0,0 +1,33 @@ +from dataclasses import dataclass, field +from uuid import UUID, uuid4 + + +@dataclass +class ForecastScenario: + id: UUID = field(default_factory=uuid4) + name: str = "baseline" + income_multiplier: float = 1.0 + expense_multiplier: float = 1.0 + growth_multiplier: float = 1.0 + description: str = "" + + def to_dict(self) -> dict: + return { + "id": str(self.id), + "name": self.name, + "income_multiplier": self.income_multiplier, + "expense_multiplier": self.expense_multiplier, + "growth_multiplier": self.growth_multiplier, + "description": self.description, + } + + @classmethod + def from_dict(cls, data: dict) -> "ForecastScenario": + return cls( + id=UUID(data["id"]), + name=data["name"], + income_multiplier=data.get("income_multiplier", 1.0), + expense_multiplier=data.get("expense_multiplier", 1.0), + growth_multiplier=data.get("growth_multiplier", 1.0), + description=data.get("description", ""), + ) diff --git a/cashflow_model/transaction.py b/cashflow_model/transaction.py new file mode 100644 index 0000000..0039556 --- /dev/null +++ b/cashflow_model/transaction.py @@ -0,0 +1,33 @@ +from dataclasses import dataclass, field +from uuid import UUID, uuid4 + + +@dataclass +class Transaction: + id: UUID = field(default_factory=uuid4) + date: str = "" + account: str = "" + category: str = "" + amount: float = 0.0 + description: str = "" + + def to_dict(self) -> dict: + return { + "id": str(self.id), + "date": self.date, + "account": self.account, + "category": self.category, + "amount": self.amount, + "description": self.description, + } + + @classmethod + def from_dict(cls, data: dict) -> "Transaction": + return cls( + id=UUID(data["id"]), + date=data["date"], + account=data.get("account", ""), + category=data.get("category", ""), + amount=data.get("amount", 0.0), + description=data.get("description", ""), + ) diff --git a/cli/__init__.py b/cli/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/cli/main.py b/cli/main.py new file mode 100644 index 0000000..69c3330 --- /dev/null +++ b/cli/main.py @@ -0,0 +1,201 @@ +import sys +from pathlib import Path + +import typer +from rich.console import Console +from rich.table import Table + +from ai.assistant import AssistantService +from cashflow_model import FinancialModel +from engine.forecast import ForecastService +from engine.scenarios import DEFAULT_SCENARIOS, ScenarioService +from sync.excel_sync import ExcelSync + +try: + sys.stdout.reconfigure(encoding="utf-8") +except (AttributeError, OSError): + pass + +app = typer.Typer(name="cf", help="CashFlow Forecast - personal finance model") +console = Console() + +DATA_DIR = Path("data") +MODEL_PATH = DATA_DIR / "model.json" + + +def _load_model() -> FinancialModel: + if MODEL_PATH.exists(): + return FinancialModel.load(MODEL_PATH) + return FinancialModel() + + +def _save_model(model: FinancialModel) -> None: + model.save(MODEL_PATH) + + +@app.command() +def init() -> None: + """Создать пустую финансовую модель""" + model = FinancialModel() + _save_model(model) + console.print("[green]OK[/green] Пустая модель создана в data/model.json") + + +@app.command() +def forecast( + months: int = typer.Option(12, "--months", "-m", help="Количество месяцев прогноза"), +) -> None: + """Запустить прогноз денежных потоков""" + model = _load_model() + service = ForecastService(model) + results = service.forecast_cashflow(months) + summary = service.summary(months) + + if results: + table = Table(title=f"Прогноз на {months} мес.") + table.add_column("Счёт", style="cyan") + table.add_column("Месяц", style="white") + table.add_column("Баланс", justify="right", style="green") + table.add_column("Доход", justify="right") + table.add_column("Расход", justify="right") + + for r in results: + table.add_row( + r["account"], str(r["month"]), + f"${r['balance']:.2f}", + f"${r['income']:.2f}", + f"${r['expenses']:.2f}", + ) + console.print(table) + + console.print(f"\n[bold]Итог:[/bold] Баланс: ${summary['total_balance']:.2f} | " + f"Доход: ${summary['total_income']:.2f} | " + f"Расход: ${summary['total_expenses']:.2f}") + + +@app.command() +def scenario( + name: str = typer.Argument("baseline", help="Имя сценария: baseline, optimistic, pessimistic"), + months: int = typer.Option(12, "--months", "-m", help="Количество месяцев"), +) -> None: + """Применить сценарий и показать прогноз""" + model = _load_model() + service = ScenarioService(model) + + if name in DEFAULT_SCENARIOS: + scenario_obj = DEFAULT_SCENARIOS[name] + else: + console.print(f"[red]Неизвестный сценарий: {name}[/red]") + console.print(f"Доступны: {', '.join(DEFAULT_SCENARIOS.keys())}") + raise typer.Exit(1) + + result = service.apply(scenario_obj, months) + console.print(f"[bold]Сценарий:[/bold] {result['scenario']}") + console.print(f"[dim]{result['scenario_description']}[/dim]") + console.print(f"Баланс: ${result['total_balance']:.2f}") + console.print(f"Доход: ${result['total_income']:.2f}") + console.print(f"Расход: ${result['total_expenses']:.2f}") + + +@app.command() +def whatif( + income_mult: float = typer.Option(1.0, "--income", "-i", help="Множитель дохода"), + expense_mult: float = typer.Option(1.0, "--expense", "-e", help="Множитель расхода"), + growth_mult: float = typer.Option(1.0, "--growth", "-g", help="Множитель роста активов"), + months: int = typer.Option(12, "--months", "-m", help="Количество месяцев"), +) -> None: + """What-if анализ с произвольными множителями""" + model = _load_model() + service = ScenarioService(model) + result = service.what_if(income_mult, expense_mult, growth_mult, months) + + console.print("[bold]What-if анализ[/bold]") + console.print(f"Доход x{income_mult} | Расход x{expense_mult} | Рост x{growth_mult}") + console.print(f"Баланс: ${result['total_balance']:.2f}") + console.print(f"Доход: ${result['total_income']:.2f}") + console.print(f"Расход: ${result['total_expenses']:.2f}") + + +@app.command() +def compare( + months: int = typer.Option(12, "--months", "-m", help="Количество месяцев"), +) -> None: + """Сравнить все сценарии""" + model = _load_model() + service = ScenarioService(model) + results = service.compare(months) + + table = Table(title="Сравнение сценариев") + table.add_column("Сценарий", style="cyan") + table.add_column("Баланс", justify="right") + table.add_column("Доход", justify="right") + table.add_column("Расход", justify="right") + + for name, r in results.items(): + table.add_row( + name, + f"${r['total_balance']:.2f}", + f"${r['total_income']:.2f}", + f"${r['total_expenses']:.2f}", + ) + console.print(table) + + +@app.command() +def import_xlsx( + path: str = typer.Argument(..., help="Путь к .xlsx файлу"), +) -> None: + """Импорт данных из Excel""" + sync = ExcelSync() + try: + model = sync.import_model(path) + _save_model(model) + console.print(f"[green]OK[/green] Импортировано: {len(model.accounts)} счетов, " + f"{len(model.transactions)} транзакций, " + f"{len(model.recurring)} регулярных платежей, " + f"{len(model.assets)} активов, " + f"{len(model.liabilities)} обязательств") + except Exception as e: + console.print(f"[red]Ошибка импорта: {e}[/red]") + raise typer.Exit(1) + + +@app.command() +def export_xlsx( + path: str = typer.Argument("exports/forecast.xlsx", help="Путь для .xlsx файла"), +) -> None: + """Экспорт модели в Excel""" + model = _load_model() + sync = ExcelSync() + try: + sync.export_model(model, path) + console.print(f"[green]OK[/green] Модель экспортирована в {path}") + except Exception as e: + console.print(f"[red]Ошибка экспорта: {e}[/red]") + raise typer.Exit(1) + + +@app.command() +def analyze( + months: int = typer.Option(12, "--months", "-m", help="Количество месяцев для анализа"), +) -> None: + """AI-анализ финансовой модели""" + model = _load_model() + assistant = AssistantService(model) + result = assistant.analyze(months) + + console.print("[bold]Промпт для AI:[/bold]") + console.print(result["prompt"][:500] + "...\n") + + console.print("[bold]Сводка:[/bold]") + s = result["summary"] + console.print(f"Баланс: ${s['total_balance']:.2f}") + console.print(f"Доход: ${s['total_income']:.2f}") + console.print(f"Расход: ${s['total_expenses']:.2f}") + console.print( + "\n[yellow]AI-ответ: заглушка. Подключите реальный API в ai/assistant.py[/yellow]" + ) + + +if __name__ == "__main__": + app() diff --git a/data/.gitkeep b/data/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/data/model.json b/data/model.json new file mode 100644 index 0000000..c6b8eb4 --- /dev/null +++ b/data/model.json @@ -0,0 +1,8 @@ +{ + "accounts": [], + "transactions": [], + "recurring": [], + "assets": [], + "liabilities": [], + "scenarios": [] +} \ No newline at end of file diff --git a/engine/__init__.py b/engine/__init__.py new file mode 100644 index 0000000..627c049 --- /dev/null +++ b/engine/__init__.py @@ -0,0 +1,10 @@ +from engine.forecast import ForecastError, ForecastService +from engine.scenarios import DEFAULT_SCENARIOS, ScenarioError, ScenarioService + +__all__ = [ + "ForecastService", + "ForecastError", + "ScenarioService", + "ScenarioError", + "DEFAULT_SCENARIOS", +] diff --git a/engine/forecast.py b/engine/forecast.py new file mode 100644 index 0000000..9b33d4a --- /dev/null +++ b/engine/forecast.py @@ -0,0 +1,100 @@ +from copy import deepcopy + +from cashflow_model import Account, FinancialModel + + +class ForecastError(Exception): + pass + + +class ForecastService: + def __init__(self, model: FinancialModel): + self.model = deepcopy(model) + + def forecast_cashflow(self, months: int = 12) -> list[dict]: + if months < 1: + raise ForecastError("months must be >= 1") + + results = [] + for account in self.model.accounts: + balance = account.balance + monthly = self._project_account(account, months) + for m in range(months): + balance = monthly[m]["balance"] + results.append({ + "account": account.name, + "month": m + 1, + "balance": round(balance, 2), + "income": round(monthly[m]["income"], 2), + "expenses": round(monthly[m]["expenses"], 2), + }) + return results + + def _project_account(self, account: Account, months: int) -> list[dict]: + balance = account.balance + monthly = [] + for m in range(months): + income = 0.0 + expenses = 0.0 + + for t in self.model.transactions: + if t.account == str(account.id): + if t.amount > 0: + income += t.amount + else: + expenses += abs(t.amount) + + for r in self.model.recurring: + if r.category == "income": + income += abs(r.amount) + else: + expenses += abs(r.amount) + + income += self._asset_income(account) + expenses += self._liability_cost(account) + + balance += income - expenses + + asset_growth = sum( + a.value * a.growth_rate / 12 + for a in self.model.assets + ) + balance += asset_growth + + monthly.append({ + "balance": balance, + "income": income, + "expenses": expenses, + }) + return monthly + + def _asset_income(self, account: Account) -> float: + return sum( + a.value * a.growth_rate / 12 + for a in self.model.assets + ) + + def _liability_cost(self, account: Account) -> float: + total = 0.0 + for liability in self.model.liabilities: + interest_cost = liability.balance * liability.interest / 100 / 12 + total += interest_cost + liability.balance -= liability.payment - interest_cost + if liability.balance < 0: + liability.balance = 0 + return total + + def summary(self, months: int = 12) -> dict: + results = self.forecast_cashflow(months) + if not results: + return {"total_balance": 0, "total_income": 0, "total_expenses": 0, "months": months} + + final = results[-1] + all_income = sum(r["income"] for r in results) + all_expenses = sum(r["expenses"] for r in results) + return { + "total_balance": final["balance"], + "total_income": round(all_income, 2), + "total_expenses": round(all_expenses, 2), + "months": months, + } diff --git a/engine/scenarios.py b/engine/scenarios.py new file mode 100644 index 0000000..dcca293 --- /dev/null +++ b/engine/scenarios.py @@ -0,0 +1,87 @@ +from copy import deepcopy + +from cashflow_model import FinancialModel, ForecastScenario +from engine.forecast import ForecastService + + +class ScenarioError(Exception): + pass + + +DEFAULT_SCENARIOS = { + "baseline": ForecastScenario( + name="baseline", + income_multiplier=1.0, + expense_multiplier=1.0, + growth_multiplier=1.0, + description="Базовый сценарий без изменений", + ), + "optimistic": ForecastScenario( + name="optimistic", + income_multiplier=1.15, + expense_multiplier=0.95, + growth_multiplier=1.2, + description="Оптимистичный: доход +15%, расход -5%, рост активов +20%", + ), + "pessimistic": ForecastScenario( + name="pessimistic", + income_multiplier=0.85, + expense_multiplier=1.1, + growth_multiplier=0.8, + description="Пессимистичный: доход -15%, расход +10%, рост активов -20%", + ), +} + + +class ScenarioService: + def __init__(self, model: FinancialModel): + self.model = deepcopy(model) + + def apply(self, scenario: ForecastScenario, months: int = 12) -> dict: + model = deepcopy(self.model) + + for t in model.transactions: + if t.amount > 0: + t.amount *= scenario.income_multiplier + else: + t.amount *= scenario.expense_multiplier + + for r in model.recurring: + if r.category == "income": + r.amount *= scenario.income_multiplier + else: + r.amount *= scenario.expense_multiplier + + for a in model.assets: + a.growth_rate *= scenario.growth_multiplier + + service = ForecastService(model) + result = service.summary(months) + result["scenario"] = scenario.name + result["scenario_description"] = scenario.description + return result + + def compare(self, months: int = 12) -> dict: + results = {} + for name, scenario in DEFAULT_SCENARIOS.items(): + results[name] = self.apply(scenario, months) + return results + + def what_if( + self, + income_mult: float = 1.0, + expense_mult: float = 1.0, + growth_mult: float = 1.0, + months: int = 12, + ) -> dict: + scenario = ForecastScenario( + name="what-if", + income_multiplier=income_mult, + expense_multiplier=expense_mult, + growth_multiplier=growth_mult, + description=( + f"What-if: income x{income_mult}, " + f"expense x{expense_mult}, growth x{growth_mult}" + ), + ) + return self.apply(scenario, months) diff --git a/exports/.gitkeep b/exports/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..9205743 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,30 @@ +[build-system] +requires = ["setuptools>=68.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "cashflow-forecast" +version = "0.1.0" +description = "Личная финансовая модель с прогнозом денежных потоков" +requires-python = ">=3.11" +dependencies = [ + "openpyxl>=3.1", + "typer>=0.9", + "rich>=13.0", +] + +[project.scripts] +cf = "cli.main:app" + +[tool.setuptools.packages.find] +include = ["cashflow_model*", "sync*", "engine*", "ai*", "cli*"] + +[tool.ruff] +target-version = "py311" +line-length = 100 + +[tool.ruff.lint] +select = ["E", "F", "I", "N", "W"] + +[tool.pytest.ini_options] +testpaths = ["tests"] diff --git a/sync/__init__.py b/sync/__init__.py new file mode 100644 index 0000000..0a965fb --- /dev/null +++ b/sync/__init__.py @@ -0,0 +1,3 @@ +from sync.excel_sync import ExcelSync, SyncError + +__all__ = ["ExcelSync", "SyncError"] diff --git a/sync/excel_sync.py b/sync/excel_sync.py new file mode 100644 index 0000000..8525b01 --- /dev/null +++ b/sync/excel_sync.py @@ -0,0 +1,108 @@ +from pathlib import Path +from uuid import UUID + +from openpyxl import Workbook, load_workbook + +from cashflow_model import Account, Asset, FinancialModel, Liability, RecurringCashflow, Transaction + + +class SyncError(Exception): + pass + + +_SHEET_CONFIG = { + "Accounts": { + "fields": ["id", "name", "currency", "balance"], + "cls": Account, + }, + "Transactions": { + "fields": ["id", "date", "account", "category", "amount", "description"], + "cls": Transaction, + }, + "Recurring": { + "fields": ["id", "start_date", "end_date", "frequency", "amount", "category"], + "cls": RecurringCashflow, + }, + "Assets": { + "fields": ["id", "name", "value", "growth_rate"], + "cls": Asset, + }, + "Liabilities": { + "fields": ["id", "name", "balance", "interest", "payment"], + "cls": Liability, + }, +} + + +class ExcelSync: + def import_model(self, path: str | Path) -> FinancialModel: + path = Path(path) + if not path.exists(): + raise SyncError(f"File not found: {path}") + + wb = load_workbook(path, read_only=True, data_only=True) + model = FinancialModel() + + for sheet_name, config in _SHEET_CONFIG.items(): + if sheet_name not in wb.sheetnames: + continue + ws = wb[sheet_name] + rows = list(ws.iter_rows(values_only=True)) + if len(rows) < 2: + continue + + headers = [str(h).strip().lower() if h else "" for h in rows[0]] + for row in rows[1:]: + if not any(v is not None for v in row): + continue + data = {} + for i, header in enumerate(headers): + val = row[i] if i < len(row) else None + if val is not None: + data[header] = str(val) if not isinstance(val, (int, float)) else val + self._add_to_model(model, sheet_name, data) + + wb.close() + return model + + def export_model(self, model: FinancialModel, path: str | Path) -> None: + path = Path(path) + wb = Workbook() + wb.remove(wb.active) + + collections = { + "Accounts": model.accounts, + "Transactions": model.transactions, + "Recurring": model.recurring, + "Assets": model.assets, + "Liabilities": model.liabilities, + } + + for sheet_name, items in collections.items(): + config = _SHEET_CONFIG[sheet_name] + ws = wb.create_sheet(title=sheet_name) + ws.append(config["fields"]) + for item in items: + row = [ + str(getattr(item, f)) if isinstance(getattr(item, f), UUID) + else getattr(item, f) + for f in config["fields"] + ] + ws.append(row) + + wb.save(path) + + def _add_to_model(self, model: FinancialModel, sheet_name: str, data: dict) -> None: + try: + if sheet_name == "Accounts": + model.accounts.append(Account.from_dict(data)) + elif sheet_name == "Transactions": + model.transactions.append(Transaction.from_dict(data)) + elif sheet_name == "Recurring": + model.recurring.append(RecurringCashflow.from_dict(data)) + elif sheet_name == "Assets": + model.assets.append(Asset.from_dict(data)) + elif sheet_name == "Liabilities": + model.liabilities.append(Liability.from_dict(data)) + except Exception as e: + raise SyncError(f"Failed to parse row in {sheet_name}: {e}") from e diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..0038e23 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,56 @@ +from uuid import uuid4 + +import pytest + +from cashflow_model import ( + Account, + Asset, + FinancialModel, + Liability, + RecurringCashflow, + Transaction, +) + + +@pytest.fixture +def sample_model() -> FinancialModel: + acc_id = uuid4() + return FinancialModel( + accounts=[ + Account(id=acc_id, name="Основной счёт", currency="USD", balance=5000.0), + Account(name="Сбережения", currency="USD", balance=10000.0), + ], + transactions=[ + Transaction( + date="2026-01-01", account=str(acc_id), + category="income", amount=3000.0, + description="Зарплата", + ), + Transaction( + date="2026-01-05", account=str(acc_id), + category="rent", amount=-1200.0, + description="Аренда", + ), + ], + recurring=[ + RecurringCashflow( + start_date="2026-01-01", frequency="monthly", + amount=500.0, category="income", + ), + RecurringCashflow( + start_date="2026-01-01", frequency="monthly", + amount=-200.0, category="subscription", + ), + ], + assets=[ + Asset(name="Акции", value=50000.0, growth_rate=8.0), + ], + liabilities=[ + Liability(name="Кредит", balance=20000.0, interest=5.0, payment=500.0), + ], + ) + + +@pytest.fixture +def empty_model() -> FinancialModel: + return FinancialModel() diff --git a/tests/test_ai.py b/tests/test_ai.py new file mode 100644 index 0000000..2e3579d --- /dev/null +++ b/tests/test_ai.py @@ -0,0 +1,24 @@ +from ai.assistant import AssistantService +from ai.prompts import ADVICE_PROMPT, ANALYZE_PROMPT + + +class TestAssistantService: + def test_analyze_returns_prompt(self, sample_model): + assistant = AssistantService(sample_model) + result = assistant.analyze(months=6) + assert "prompt" in result + assert "summary" in result + assert "forecast" in result + assert result["ai_response"] is None + + def test_advice_returns_prompt(self, sample_model): + assistant = AssistantService(sample_model) + result = assistant.advice("Как мне сэкономить?", months=6) + assert "prompt" in result + assert "ai_response" in result + + def test_prompt_templates(self): + assert "{model_json}" in ANALYZE_PROMPT + assert "{forecast_json}" in ANALYZE_PROMPT + assert "{model_json}" in ADVICE_PROMPT + assert "{question}" in ADVICE_PROMPT diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..5c1a63c --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,25 @@ +from typer.testing import CliRunner + +from cli.main import app + +runner = CliRunner() + + +class TestCli: + def test_init(self): + result = runner.invoke(app, ["init"]) + assert result.exit_code == 0 + assert "Пустая модель" in result.stdout + + def test_forecast_after_init(self): + runner.invoke(app, ["init"]) + result = runner.invoke(app, ["forecast", "--months", "3"]) + assert result.exit_code == 0 + + def test_unknown_scenario(self): + result = runner.invoke(app, ["scenario", "unknown"]) + assert result.exit_code != 0 + + def test_help(self): + result = runner.invoke(app, ["--help"]) + assert result.exit_code == 0 diff --git a/tests/test_excel_sync.py b/tests/test_excel_sync.py new file mode 100644 index 0000000..1c01d8b --- /dev/null +++ b/tests/test_excel_sync.py @@ -0,0 +1,39 @@ +from pathlib import Path + +from cashflow_model import FinancialModel +from sync.excel_sync import ExcelSync, SyncError + + +class TestExcelSync: + def test_export_creates_file(self, sample_model, tmp_path: Path): + sync = ExcelSync() + p = tmp_path / "test.xlsx" + sync.export_model(sample_model, p) + assert p.exists() + + def test_export_import_roundtrip(self, sample_model, tmp_path: Path): + sync = ExcelSync() + p = tmp_path / "roundtrip.xlsx" + sync.export_model(sample_model, p) + + loaded = sync.import_model(p) + assert len(loaded.accounts) == len(sample_model.accounts) + assert len(loaded.transactions) == len(sample_model.transactions) + assert len(loaded.recurring) == len(sample_model.recurring) + assert len(loaded.assets) == len(sample_model.assets) + assert len(loaded.liabilities) == len(sample_model.liabilities) + + def test_import_missing_file(self): + sync = ExcelSync() + try: + sync.import_model("nonexistent.xlsx") + assert False, "Expected SyncError" + except SyncError: + pass + + def test_export_empty_model(self, tmp_path: Path): + sync = ExcelSync() + model = FinancialModel() + p = tmp_path / "empty.xlsx" + sync.export_model(model, p) + assert p.exists() diff --git a/tests/test_forecast.py b/tests/test_forecast.py new file mode 100644 index 0000000..bfeba7b --- /dev/null +++ b/tests/test_forecast.py @@ -0,0 +1,36 @@ +import pytest + +from engine.forecast import ForecastError, ForecastService + + +class TestForecastService: + def test_forecast_returns_results(self, sample_model): + service = ForecastService(sample_model) + results = service.forecast_cashflow(months=12) + assert len(results) > 0 + assert "balance" in results[0] + assert "month" in results[0] + + def test_forecast_12_months(self, sample_model): + service = ForecastService(sample_model) + results = service.forecast_cashflow(months=12) + months = set(r["month"] for r in results) + assert max(months) == 12 + + def test_invalid_months(self, sample_model): + service = ForecastService(sample_model) + with pytest.raises(ForecastError): + service.forecast_cashflow(months=0) + + def test_summary(self, sample_model): + service = ForecastService(sample_model) + s = service.summary(months=6) + assert "total_balance" in s + assert "total_income" in s + assert "total_expenses" in s + assert s["months"] == 6 + + def test_empty_model(self, empty_model): + service = ForecastService(empty_model) + results = service.forecast_cashflow(months=3) + assert results == [] diff --git a/tests/test_model.py b/tests/test_model.py new file mode 100644 index 0000000..0d61fa2 --- /dev/null +++ b/tests/test_model.py @@ -0,0 +1,82 @@ +from pathlib import Path + +from cashflow_model import ( + Account, + Asset, + FinancialModel, + ForecastScenario, + Liability, + RecurringCashflow, + Transaction, +) + + +class TestAccount: + def test_create(self): + a = Account(name="Test", balance=100.0) + assert a.name == "Test" + assert a.balance == 100.0 + assert a.currency == "USD" + + def test_to_dict_roundtrip(self): + a = Account(name="Test", balance=100.0) + d = a.to_dict() + a2 = Account.from_dict(d) + assert a2.name == a.name + assert a2.balance == a.balance + assert a2.currency == a.currency + + +class TestTransaction: + def test_create(self): + t = Transaction(amount=500.0, category="food") + assert t.amount == 500.0 + + def test_roundtrip(self): + t = Transaction(amount=-100.0, category="rent", description="test") + d = t.to_dict() + t2 = Transaction.from_dict(d) + assert t2.amount == t.amount + assert t2.category == t.category + assert t2.description == t.description + + +class TestFinancialModel: + def test_save_load(self, tmp_path: Path): + model = FinancialModel() + model.accounts.append(Account(name="Test", balance=100.0)) + model.transactions.append(Transaction(amount=50.0, category="income")) + + p = tmp_path / "model.json" + model.save(p) + assert p.exists() + + loaded = FinancialModel.load(p) + assert len(loaded.accounts) == 1 + assert len(loaded.transactions) == 1 + assert loaded.accounts[0].name == "Test" + + def test_empty_model(self): + model = FinancialModel() + d = model.to_dict() + assert d["accounts"] == [] + assert d["transactions"] == [] + + def test_all_entities_roundtrip(self, tmp_path: Path): + model = FinancialModel( + accounts=[Account(name="A"), Account(name="B")], + transactions=[Transaction(amount=100.0)], + recurring=[RecurringCashflow(amount=50.0)], + assets=[Asset(name="Stock", value=1000.0)], + liabilities=[Liability(name="Loan", balance=500.0, interest=5.0, payment=100.0)], + scenarios=[ForecastScenario(name="test")], + ) + p = tmp_path / "full.json" + model.save(p) + loaded = FinancialModel.load(p) + assert len(loaded.accounts) == 2 + assert len(loaded.transactions) == 1 + assert len(loaded.recurring) == 1 + assert len(loaded.assets) == 1 + assert len(loaded.liabilities) == 1 + assert len(loaded.scenarios) == 1 diff --git a/tests/test_scenarios.py b/tests/test_scenarios.py new file mode 100644 index 0000000..e70ab24 --- /dev/null +++ b/tests/test_scenarios.py @@ -0,0 +1,21 @@ +from engine.scenarios import DEFAULT_SCENARIOS, ScenarioService + + +class TestScenarioService: + def test_baseline(self, sample_model): + service = ScenarioService(sample_model) + result = service.apply(DEFAULT_SCENARIOS["baseline"], months=6) + assert result["scenario"] == "baseline" + assert result["total_balance"] is not None + + def test_compare_returns_three(self, sample_model): + service = ScenarioService(sample_model) + results = service.compare(months=6) + assert "baseline" in results + assert "optimistic" in results + assert "pessimistic" in results + + def test_what_if(self, sample_model): + service = ScenarioService(sample_model) + result = service.what_if(income_mult=1.2, expense_mult=0.9, months=6) + assert result["scenario"] == "what-if"