bootstrap
This commit is contained in:
@@ -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.
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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 <file.xlsx>` | Импорт данных из Excel в JSON | `cf import data.xlsx` |
|
||||||
|
| `export <file.xlsx>` | Экспорт из JSON в Excel | `cf export report.xlsx` |
|
||||||
|
| `forecast [--months 12]` | Запуск прогноза | `cf forecast --months 12` |
|
||||||
|
| `scenario <name>` | Применить сценарий | `cf scenario optimistic` |
|
||||||
|
| `analyze` | AI-анализ модели | `cf analyze` |
|
||||||
|
| `init` | Инициализация пустой модели | `cf init` |
|
||||||
|
|
||||||
|
## 6. Обработка ошибок
|
||||||
|
|
||||||
|
- **Стратегия:** Исключения Python с кастомными типами (`ModelError`, `SyncError`, `ForecastError`)
|
||||||
|
- **Формат ошибок:** `{ "error": "<message>", "code": "<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 — только интерфейс (заглушка с промптами). Сценарии — базовая реализация.
|
||||||
@@ -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`
|
||||||
|
Актуальное состояние чекпоинтов прилагается.
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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 для тестовой модели.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
from ai import prompts
|
||||||
|
from ai.assistant import AssistantError, AssistantService
|
||||||
|
|
||||||
|
__all__ = ["AssistantService", "AssistantError", "prompts"]
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -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),
|
||||||
|
)
|
||||||
@@ -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),
|
||||||
|
)
|
||||||
@@ -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),
|
||||||
|
)
|
||||||
@@ -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)
|
||||||
@@ -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", ""),
|
||||||
|
)
|
||||||
@@ -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", ""),
|
||||||
|
)
|
||||||
@@ -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", ""),
|
||||||
|
)
|
||||||
+201
@@ -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()
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"accounts": [],
|
||||||
|
"transactions": [],
|
||||||
|
"recurring": [],
|
||||||
|
"assets": [],
|
||||||
|
"liabilities": [],
|
||||||
|
"scenarios": []
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
@@ -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"]
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from sync.excel_sync import ExcelSync, SyncError
|
||||||
|
|
||||||
|
__all__ = ["ExcelSync", "SyncError"]
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -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 == []
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
Reference in New Issue
Block a user