testing new features
This commit is contained in:
+9
-12
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"metaagent_version": "1.1.0",
|
"metaagent_version": "1.1.0",
|
||||||
"session_id": "metaagent-002",
|
"session_id": "metaagent-003",
|
||||||
"target_repo": "S:\\Git\\nifodea",
|
"target_repo": "S:\\Git\\nifodea",
|
||||||
"goal": "Обновление metaagent-артефактов до v1.0.0, валидация существующего кода и окружения",
|
"goal": "i18n (ru/en) — инфраструктура, обёртка строк, контроль переводов",
|
||||||
"project_type": "existing",
|
"project_type": "existing",
|
||||||
"config": {
|
"config": {
|
||||||
"depth": 4,
|
"depth": 4,
|
||||||
@@ -18,17 +18,14 @@
|
|||||||
"red_team": "skipped",
|
"red_team": "skipped",
|
||||||
"decomposition": "completed",
|
"decomposition": "completed",
|
||||||
"environment": "completed",
|
"environment": "completed",
|
||||||
"handoff": "completed"
|
"handoff": "pending"
|
||||||
},
|
},
|
||||||
"tasks": [
|
"tasks": [
|
||||||
{ "id": "T1", "title": "Инициализация проекта и зависимостей", "status": "completed", "depends_on": [], "acceptance_criteria": ["pyproject.toml создан", "Все __init__.py созданы", "ruff проходит", "pytest запускается"] },
|
{ "id": "T9", "title": "i18n инфраструктура (cli/i18n.py)", "status": "completed", "depends_on": [], "acceptance_criteria": ["Translator, t(), setup_i18n(), set_lang()", "ru default, en fallback"] },
|
||||||
{ "id": "T2", "title": "Модель данных (dataclass + JSON)", "status": "completed", "depends_on": ["T1"], "acceptance_criteria": ["Все сущности dataclass", "FinancialModel save/load JSON"] },
|
{ "id": "T10", "title": "Обёртка CLI-строк в t()", "status": "completed", "depends_on": ["T9"], "acceptance_criteria": ["main.py + config.py через t()", "ruff check проходит"] },
|
||||||
{ "id": "T3", "title": "Forecast Engine", "status": "completed", "depends_on": ["T2"], "acceptance_criteria": ["forecast_cashflow работает", "recurring проецируются", "активы/обязательства учтены"] },
|
{ "id": "T11", "title": "AI-промпты через i18n", "status": "completed", "depends_on": ["T9"], "acceptance_criteria": ["prompts.py использует t()"] },
|
||||||
{ "id": "T4", "title": "Scenario Analysis", "status": "completed", "depends_on": ["T3"], "acceptance_criteria": ["3 сценария", "what-if модификация", "сравнение сценариев"] },
|
{ "id": "T12", "title": "Тесты i18n", "status": "completed", "depends_on": ["T9"], "acceptance_criteria": ["pytest проходит"] },
|
||||||
{ "id": "T5", "title": "Excel Sync", "status": "completed", "depends_on": ["T2"], "acceptance_criteria": ["импорт из Excel", "экспорт в Excel", "ошибки невалидного формата"] },
|
{ "id": "T13", "title": "Аудит и контроль актуальности переводов", "status": "pending", "depends_on": ["T9"], "acceptance_criteria": ["en-словарь синхронизирован с ru"] }
|
||||||
{ "id": "T6", "title": "CLI (Typer)", "status": "completed", "depends_on": ["T2","T3","T4","T5","T7"], "acceptance_criteria": ["init/forecast/analyze/import/export/scenario/whatif/compare команды"] },
|
|
||||||
{ "id": "T7", "title": "AI Assistant", "status": "completed", "depends_on": ["T3"], "acceptance_criteria": ["промпты с моделью и прогнозом", "заглушка ответа"] },
|
|
||||||
{ "id": "T8", "title": "Тесты", "status": "completed", "depends_on": ["T2","T3","T4","T5","T6","T7"], "acceptance_criteria": ["pytest проходит", "покрытие всех модулей"] }
|
|
||||||
],
|
],
|
||||||
"last_updated": "2026-07-22T12:00:00Z"
|
"last_updated": "2026-07-22T18:00:00Z"
|
||||||
}
|
}
|
||||||
|
|||||||
+51
-143
@@ -1,171 +1,79 @@
|
|||||||
{
|
{
|
||||||
"$schema": "metaagent-task-manifest",
|
"$schema": "metaagent-task-manifest",
|
||||||
"version": "1.0",
|
"version": "1.0",
|
||||||
"session_id": "metaagent-002",
|
"session_id": "metaagent-003",
|
||||||
"goal": "Обновление metaagent-артефактов до v1.0.0, валидация существующего кода и окружения",
|
"goal": "i18n (ru/en) — инфраструктура и обёртка строк, контроль актуальности переводов",
|
||||||
"created_at": "2026-07-12T20:00:00Z",
|
"created_at": "2026-07-22T18:00:00Z",
|
||||||
"tasks": [
|
"tasks": [
|
||||||
{
|
{
|
||||||
"id": "T1",
|
"id": "T9",
|
||||||
"title": "Инициализация проекта и зависимостей",
|
"title": "i18n инфраструктура (cli/i18n.py)",
|
||||||
"description": "Создать структуру директорий, pyproject.toml, venv, установить зависимости",
|
"description": "Создан модуль cli/i18n.py с Translator, t(), set_lang(), setup_i18n(). Язык: CF_LANG (env), по умолчанию 'ru'. Русские переводы — полные, английский — скелет (fallback на ru).",
|
||||||
"type": "config",
|
"type": "feature",
|
||||||
"files": [
|
"files": ["cli/i18n.py"],
|
||||||
"pyproject.toml",
|
|
||||||
"cashflow_model/__init__.py",
|
|
||||||
"sync/__init__.py",
|
|
||||||
"engine/__init__.py",
|
|
||||||
"ai/__init__.py",
|
|
||||||
"cli/__init__.py",
|
|
||||||
"data/.gitkeep",
|
|
||||||
"exports/.gitkeep"
|
|
||||||
],
|
|
||||||
"depends_on": [],
|
"depends_on": [],
|
||||||
"acceptance_criteria": [
|
"acceptance_criteria": [
|
||||||
"pyproject.toml создан с правильными зависимостями",
|
"t() возвращает русский текст при CF_LANG=ru",
|
||||||
"Все директории модулей созданы с __init__.py",
|
"t() возвращает русский текст при CF_LANG=en (fallback)",
|
||||||
"ruff lint проходит без ошибок",
|
"t('nonexistent') возвращает 'nonexistent'",
|
||||||
"pytest запускается"
|
"setup_i18n() читает CF_LANG из окружения"
|
||||||
],
|
],
|
||||||
"status": "completed"
|
"status": "completed"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "T2",
|
"id": "T10",
|
||||||
"title": "Модель данных (dataclass + JSON serialization)",
|
"title": "Обёртка CLI-строк в t()",
|
||||||
"description": "Реализовать все сущности: Account, Transaction, RecurringCashflow, Asset, Liability, ForecastScenario, FinancialModel",
|
"description": "Все user-facing строки в cli/main.py и cli/config.py заменены на вызовы t('key', ...). Русский словарь содержит ~150 ключей.",
|
||||||
"type": "feature",
|
"type": "refactor",
|
||||||
"files": [
|
"files": ["cli/main.py", "cli/config.py"],
|
||||||
"cashflow_model/__init__.py",
|
"depends_on": ["T9"],
|
||||||
"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": [
|
"acceptance_criteria": [
|
||||||
"Все сущности — dataclass с правильными полями и типами",
|
"Все help-строки typer.Option/Argument через t()",
|
||||||
"FinancialModel корректно сохраняется и загружается из JSON",
|
"Все console.print сообщения через t()",
|
||||||
"Создание Account, Transaction, Asset, Liability через конструктор работает"
|
"Все docstrings оставлены как комментарии (typer не использует)",
|
||||||
|
"ruff check проходит"
|
||||||
],
|
],
|
||||||
"status": "completed"
|
"status": "completed"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "T3",
|
"id": "T11",
|
||||||
"title": "Forecast Engine (базовый прогноз)",
|
"title": "AI-промпты через i18n",
|
||||||
"description": "Реализовать ForecastService с методами forecast_cashflow, apply_recurring, project_balance",
|
"description": "prompt.analyze, prompt.advice, prompt.scenario_comparison добавлены в словарь i18n, prompts.py использует t().",
|
||||||
"type": "feature",
|
"type": "refactor",
|
||||||
"files": [
|
"files": ["ai/prompts.py"],
|
||||||
"engine/__init__.py",
|
"depends_on": ["T9"],
|
||||||
"engine/forecast.py"
|
|
||||||
],
|
|
||||||
"depends_on": ["T2"],
|
|
||||||
"acceptance_criteria": [
|
"acceptance_criteria": [
|
||||||
"forecast_cashflow(months=12) возвращает список помесячных балансов",
|
"ANALYZE_PROMPT = t('prompt.analyze')",
|
||||||
"Регулярные платежи корректно проецируются на будущие периоды",
|
"ADVICE_PROMPT = t('prompt.advice')",
|
||||||
"Активы учитываются с ростом (growth_rate)",
|
"SCENARIO_COMPARISON_PROMPT = t('prompt.scenario_comparison')"
|
||||||
"Обязательства учитываются с процентами и платежами"
|
|
||||||
],
|
],
|
||||||
"status": "completed"
|
"status": "completed"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "T4",
|
"id": "T12",
|
||||||
"title": "Scenario Analysis",
|
"title": "Тесты i18n",
|
||||||
"description": "Реализовать ScenarioService с методами: сценарии, what-if, сравнение",
|
"description": "test_i18n.py: базовые тесты Translator, t(), set_lang, fallback, неизвестный ключ.",
|
||||||
"type": "feature",
|
|
||||||
"files": [
|
|
||||||
"engine/__init__.py",
|
|
||||||
"engine/scenarios.py"
|
|
||||||
],
|
|
||||||
"depends_on": ["T3"],
|
|
||||||
"acceptance_criteria": [
|
|
||||||
"Три предустановленных сценария (baseline, optimistic, pessimistic)",
|
|
||||||
"What-if: изменение параметров (доход +10%, расход -5%)",
|
|
||||||
"Сравнение сценариев возвращает сводку различий"
|
|
||||||
],
|
|
||||||
"status": "completed"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "T5",
|
|
||||||
"title": "Excel Sync (import/export)",
|
|
||||||
"description": "Реализовать ExcelSync: чтение модели из .xlsx, запись результатов прогноза в .xlsx",
|
|
||||||
"type": "feature",
|
|
||||||
"files": [
|
|
||||||
"sync/__init__.py",
|
|
||||||
"sync/excel_sync.py"
|
|
||||||
],
|
|
||||||
"depends_on": ["T2"],
|
|
||||||
"acceptance_criteria": [
|
|
||||||
"Импорт из Excel заполняет FinancialModel",
|
|
||||||
"Экспорт FinancialModel в Excel создаёт корректный .xlsx",
|
|
||||||
"Обработка ошибок при невалидном формате Excel"
|
|
||||||
],
|
|
||||||
"status": "completed"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "T6",
|
|
||||||
"title": "CLI (Typer) — все команды",
|
|
||||||
"description": "Реализовать CLI через Typer с командами: init, import, export, forecast, scenario, analyze, whatif, compare",
|
|
||||||
"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' применяет и выводит сценарий",
|
|
||||||
"Команда 'cf whatif' выполняет what-if анализ",
|
|
||||||
"Команда 'cf compare' сравнивает сценарии"
|
|
||||||
],
|
|
||||||
"status": "completed"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "T7",
|
|
||||||
"title": "AI Assistant (промпты + интерфейс)",
|
|
||||||
"description": "Реализовать AssistantService: генерация промптов, заглушка для вызова AI API",
|
|
||||||
"type": "feature",
|
|
||||||
"files": [
|
|
||||||
"ai/__init__.py",
|
|
||||||
"ai/prompts.py",
|
|
||||||
"ai/assistant.py"
|
|
||||||
],
|
|
||||||
"depends_on": ["T3"],
|
|
||||||
"acceptance_criteria": [
|
|
||||||
"Промпт 'analyze' включает модель и прогноз в JSON",
|
|
||||||
"Промпт 'advice' формирует запрос на финансовые рекомендации",
|
|
||||||
"AssistantService возвращает структурированный ответ (заглушка)"
|
|
||||||
],
|
|
||||||
"status": "completed"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "T8",
|
|
||||||
"title": "Тесты на все модули",
|
|
||||||
"description": "Написать pytest-тесты для всех модулей",
|
|
||||||
"type": "test",
|
"type": "test",
|
||||||
"files": [
|
"files": ["tests/test_i18n.py"],
|
||||||
"tests/test_model.py",
|
"depends_on": ["T9"],
|
||||||
"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": [
|
"acceptance_criteria": [
|
||||||
"pytest запускается и все тесты проходят",
|
"pytest tests/test_i18n.py проходит",
|
||||||
"Покрытие базовых сценариев для каждой сущности",
|
"Покрытие: ru default, en fallback, неизвестный ключ, format args"
|
||||||
"Roundtrip-тест Excel: export → import → compare",
|
|
||||||
"Forecast-тест: известные входные данные → ожидаемый результат"
|
|
||||||
],
|
],
|
||||||
"status": "completed"
|
"status": "completed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "T13",
|
||||||
|
"title": "Аудит и контроль актуальности переводов",
|
||||||
|
"description": "Периодическая проверка: все ли ключи из TRANSLATIONS['ru'] имеют соответствующий перевод в TRANSLATIONS['en']. При добавлении новых фич — новые ключи должны добавляться в оба словаря.",
|
||||||
|
"type": "audit",
|
||||||
|
"files": ["cli/i18n.py"],
|
||||||
|
"depends_on": ["T9"],
|
||||||
|
"acceptance_criteria": [
|
||||||
|
"Все ru-ключи имеют en-перевод или fallback",
|
||||||
|
"При добавлении нового t('key') он регистрируется в _r()"
|
||||||
|
],
|
||||||
|
"status": "pending"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
+15
-2
@@ -1,7 +1,7 @@
|
|||||||
import json
|
import json
|
||||||
|
|
||||||
from ai import prompts
|
from ai import prompts
|
||||||
from cashflow_model import FinancialModel
|
from cashflow_model import CurrencyConverter, FinancialModel
|
||||||
from engine.forecast import ForecastService
|
from engine.forecast import ForecastService
|
||||||
|
|
||||||
|
|
||||||
@@ -10,8 +10,15 @@ class AssistantError(Exception):
|
|||||||
|
|
||||||
|
|
||||||
class AssistantService:
|
class AssistantService:
|
||||||
def __init__(self, model: FinancialModel):
|
def __init__(
|
||||||
|
self,
|
||||||
|
model: FinancialModel,
|
||||||
|
converter: CurrencyConverter | None = None,
|
||||||
|
display_currency: str | None = None,
|
||||||
|
):
|
||||||
self.model = model
|
self.model = model
|
||||||
|
self.converter = converter or CurrencyConverter(model.exchange_rates)
|
||||||
|
self.display_currency = display_currency or model.base_currency
|
||||||
|
|
||||||
def analyze(self, months: int = 12) -> dict:
|
def analyze(self, months: int = 12) -> dict:
|
||||||
forecast_service = ForecastService(self.model)
|
forecast_service = ForecastService(self.model)
|
||||||
@@ -22,6 +29,8 @@ class AssistantService:
|
|||||||
model_json=json.dumps(self.model.to_dict(), indent=2, ensure_ascii=False),
|
model_json=json.dumps(self.model.to_dict(), indent=2, ensure_ascii=False),
|
||||||
forecast_json=json.dumps(forecast_result, indent=2, ensure_ascii=False),
|
forecast_json=json.dumps(forecast_result, indent=2, ensure_ascii=False),
|
||||||
months=months,
|
months=months,
|
||||||
|
base_currency=self.model.base_currency,
|
||||||
|
display_currency=self.display_currency,
|
||||||
)
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -39,6 +48,8 @@ class AssistantService:
|
|||||||
model_json=json.dumps(self.model.to_dict(), indent=2, ensure_ascii=False),
|
model_json=json.dumps(self.model.to_dict(), indent=2, ensure_ascii=False),
|
||||||
forecast_json=json.dumps(forecast_result, indent=2, ensure_ascii=False),
|
forecast_json=json.dumps(forecast_result, indent=2, ensure_ascii=False),
|
||||||
question=question,
|
question=question,
|
||||||
|
base_currency=self.model.base_currency,
|
||||||
|
display_currency=self.display_currency,
|
||||||
)
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -49,6 +60,8 @@ class AssistantService:
|
|||||||
def compare_scenarios(self, scenarios_json: str) -> dict:
|
def compare_scenarios(self, scenarios_json: str) -> dict:
|
||||||
prompt = prompts.SCENARIO_COMPARISON_PROMPT.format(
|
prompt = prompts.SCENARIO_COMPARISON_PROMPT.format(
|
||||||
scenarios_json=scenarios_json,
|
scenarios_json=scenarios_json,
|
||||||
|
base_currency=self.model.base_currency,
|
||||||
|
display_currency=self.display_currency,
|
||||||
)
|
)
|
||||||
return {
|
return {
|
||||||
"prompt": prompt,
|
"prompt": prompt,
|
||||||
|
|||||||
+13
-38
@@ -1,46 +1,21 @@
|
|||||||
ANALYZE_PROMPT = """
|
from cli.i18n import t
|
||||||
Ты — финансовый AI-ассистент. Проанализируй финансовую модель пользователя.
|
|
||||||
|
|
||||||
### Модель (JSON):
|
ANALYZE_PROMPT = t("prompt.analyze")
|
||||||
{model_json}
|
ADVICE_PROMPT = t("prompt.advice")
|
||||||
|
SCENARIO_COMPARISON_PROMPT = t("prompt.scenario_comparison")
|
||||||
### Прогноз на {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:
|
def format_context(
|
||||||
|
model_json: str,
|
||||||
|
forecast_json: str,
|
||||||
|
months: int = 12,
|
||||||
|
base_currency: str = "RUB",
|
||||||
|
display_currency: str = "RUB",
|
||||||
|
) -> str:
|
||||||
return ANALYZE_PROMPT.format(
|
return ANALYZE_PROMPT.format(
|
||||||
model_json=model_json,
|
model_json=model_json,
|
||||||
forecast_json=forecast_json,
|
forecast_json=forecast_json,
|
||||||
months=months,
|
months=months,
|
||||||
|
base_currency=base_currency,
|
||||||
|
display_currency=display_currency,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from cashflow_model.account import Account
|
from cashflow_model.account import Account
|
||||||
from cashflow_model.asset import Asset
|
from cashflow_model.asset import Asset
|
||||||
|
from cashflow_model.currency import CURRENCY_SYMBOLS, CurrencyConverter, CurrencyError, ExchangeRate
|
||||||
from cashflow_model.liability import Liability
|
from cashflow_model.liability import Liability
|
||||||
from cashflow_model.model import FinancialModel
|
from cashflow_model.model import FinancialModel
|
||||||
from cashflow_model.recurring import RecurringCashflow
|
from cashflow_model.recurring import RecurringCashflow
|
||||||
@@ -14,4 +15,8 @@ __all__ = [
|
|||||||
"Liability",
|
"Liability",
|
||||||
"ForecastScenario",
|
"ForecastScenario",
|
||||||
"FinancialModel",
|
"FinancialModel",
|
||||||
|
"ExchangeRate",
|
||||||
|
"CurrencyConverter",
|
||||||
|
"CurrencyError",
|
||||||
|
"CURRENCY_SYMBOLS",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
CURRENCY_SYMBOLS = {
|
||||||
|
"RUB": "₽",
|
||||||
|
"USD": "$",
|
||||||
|
"EUR": "€",
|
||||||
|
"GBP": "£",
|
||||||
|
"CNY": "¥",
|
||||||
|
"JPY": "¥",
|
||||||
|
"KZT": "₸",
|
||||||
|
"UAH": "₴",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ExchangeRate:
|
||||||
|
from_currency: str = "USD"
|
||||||
|
to_currency: str = "RUB"
|
||||||
|
rate: float = 80.0
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
return {
|
||||||
|
"from_currency": self.from_currency,
|
||||||
|
"to_currency": self.to_currency,
|
||||||
|
"rate": self.rate,
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: dict) -> "ExchangeRate":
|
||||||
|
return cls(
|
||||||
|
from_currency=data.get("from_currency", "USD"),
|
||||||
|
to_currency=data.get("to_currency", "RUB"),
|
||||||
|
rate=data.get("rate", 80.0),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_RATES: list[ExchangeRate] = [
|
||||||
|
ExchangeRate(from_currency="USD", to_currency="RUB", rate=80.0),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class CurrencyError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class CurrencyConverter:
|
||||||
|
def __init__(self, rates: list[ExchangeRate] | None = None):
|
||||||
|
self._rates: dict[tuple[str, str], float] = {}
|
||||||
|
if rates:
|
||||||
|
for r in rates:
|
||||||
|
self.set_rate(r.from_currency, r.to_currency, r.rate)
|
||||||
|
|
||||||
|
def set_rate(self, from_currency: str, to_currency: str, rate: float) -> None:
|
||||||
|
if rate <= 0:
|
||||||
|
raise CurrencyError(f"Rate must be positive: {rate}")
|
||||||
|
self._rates[(from_currency, to_currency)] = rate
|
||||||
|
inverse = 1.0 / rate
|
||||||
|
self._rates[(to_currency, from_currency)] = inverse
|
||||||
|
|
||||||
|
def get_rate(self, from_currency: str, to_currency: str) -> float:
|
||||||
|
if from_currency == to_currency:
|
||||||
|
return 1.0
|
||||||
|
try:
|
||||||
|
return self._rates[(from_currency, to_currency)]
|
||||||
|
except KeyError:
|
||||||
|
raise CurrencyError(f"No exchange rate: {from_currency} → {to_currency}")
|
||||||
|
|
||||||
|
def convert(self, amount: float, from_currency: str, to_currency: str) -> float:
|
||||||
|
if from_currency == to_currency:
|
||||||
|
return amount
|
||||||
|
rate = self.get_rate(from_currency, to_currency)
|
||||||
|
return round(amount * rate, 2)
|
||||||
|
|
||||||
|
def get_symbol(self, currency: str) -> str:
|
||||||
|
return CURRENCY_SYMBOLS.get(currency, currency)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def with_defaults(cls) -> "CurrencyConverter":
|
||||||
|
return cls(DEFAULT_RATES)
|
||||||
@@ -4,6 +4,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
from cashflow_model.account import Account
|
from cashflow_model.account import Account
|
||||||
from cashflow_model.asset import Asset
|
from cashflow_model.asset import Asset
|
||||||
|
from cashflow_model.currency import DEFAULT_RATES, ExchangeRate
|
||||||
from cashflow_model.liability import Liability
|
from cashflow_model.liability import Liability
|
||||||
from cashflow_model.recurring import RecurringCashflow
|
from cashflow_model.recurring import RecurringCashflow
|
||||||
from cashflow_model.scenario import ForecastScenario
|
from cashflow_model.scenario import ForecastScenario
|
||||||
@@ -12,32 +13,38 @@ from cashflow_model.transaction import Transaction
|
|||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class FinancialModel:
|
class FinancialModel:
|
||||||
|
base_currency: str = "RUB"
|
||||||
accounts: list[Account] = field(default_factory=list)
|
accounts: list[Account] = field(default_factory=list)
|
||||||
transactions: list[Transaction] = field(default_factory=list)
|
transactions: list[Transaction] = field(default_factory=list)
|
||||||
recurring: list[RecurringCashflow] = field(default_factory=list)
|
recurring: list[RecurringCashflow] = field(default_factory=list)
|
||||||
assets: list[Asset] = field(default_factory=list)
|
assets: list[Asset] = field(default_factory=list)
|
||||||
liabilities: list[Liability] = field(default_factory=list)
|
liabilities: list[Liability] = field(default_factory=list)
|
||||||
scenarios: list[ForecastScenario] = field(default_factory=list)
|
scenarios: list[ForecastScenario] = field(default_factory=list)
|
||||||
|
exchange_rates: list[ExchangeRate] = field(default_factory=lambda: DEFAULT_RATES.copy())
|
||||||
|
|
||||||
def to_dict(self) -> dict:
|
def to_dict(self) -> dict:
|
||||||
return {
|
return {
|
||||||
|
"base_currency": self.base_currency,
|
||||||
"accounts": [a.to_dict() for a in self.accounts],
|
"accounts": [a.to_dict() for a in self.accounts],
|
||||||
"transactions": [t.to_dict() for t in self.transactions],
|
"transactions": [t.to_dict() for t in self.transactions],
|
||||||
"recurring": [r.to_dict() for r in self.recurring],
|
"recurring": [r.to_dict() for r in self.recurring],
|
||||||
"assets": [a.to_dict() for a in self.assets],
|
"assets": [a.to_dict() for a in self.assets],
|
||||||
"liabilities": [li.to_dict() for li in self.liabilities],
|
"liabilities": [li.to_dict() for li in self.liabilities],
|
||||||
"scenarios": [s.to_dict() for s in self.scenarios],
|
"scenarios": [s.to_dict() for s in self.scenarios],
|
||||||
|
"exchange_rates": [r.to_dict() for r in self.exchange_rates],
|
||||||
}
|
}
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_dict(cls, data: dict) -> "FinancialModel":
|
def from_dict(cls, data: dict) -> "FinancialModel":
|
||||||
return cls(
|
return cls(
|
||||||
|
base_currency=data.get("base_currency", "RUB"),
|
||||||
accounts=[Account.from_dict(a) for a in data.get("accounts", [])],
|
accounts=[Account.from_dict(a) for a in data.get("accounts", [])],
|
||||||
transactions=[Transaction.from_dict(t) for t in data.get("transactions", [])],
|
transactions=[Transaction.from_dict(t) for t in data.get("transactions", [])],
|
||||||
recurring=[RecurringCashflow.from_dict(r) for r in data.get("recurring", [])],
|
recurring=[RecurringCashflow.from_dict(r) for r in data.get("recurring", [])],
|
||||||
assets=[Asset.from_dict(a) for a in data.get("assets", [])],
|
assets=[Asset.from_dict(a) for a in data.get("assets", [])],
|
||||||
liabilities=[Liability.from_dict(li) for li in data.get("liabilities", [])],
|
liabilities=[Liability.from_dict(li) for li in data.get("liabilities", [])],
|
||||||
scenarios=[ForecastScenario.from_dict(s) for s in data.get("scenarios", [])],
|
scenarios=[ForecastScenario.from_dict(s) for s in data.get("scenarios", [])],
|
||||||
|
exchange_rates=[ExchangeRate.from_dict(r) for r in data.get("exchange_rates", [])],
|
||||||
)
|
)
|
||||||
|
|
||||||
def save(self, path: str | Path) -> None:
|
def save(self, path: str | Path) -> None:
|
||||||
|
|||||||
+428
@@ -0,0 +1,428 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import typer
|
||||||
|
from rich.console import Console
|
||||||
|
from rich.table import Table
|
||||||
|
|
||||||
|
from cashflow_model import (
|
||||||
|
Account,
|
||||||
|
Asset,
|
||||||
|
ExchangeRate,
|
||||||
|
FinancialModel,
|
||||||
|
Liability,
|
||||||
|
RecurringCashflow,
|
||||||
|
Transaction,
|
||||||
|
)
|
||||||
|
from cli.i18n import t
|
||||||
|
|
||||||
|
app = typer.Typer(name="config", help=t("config.help"))
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
# ── base-currency ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@app.command()
|
||||||
|
def base_currency(
|
||||||
|
currency: str = typer.Argument(None, help=t("cmd.config.base_currency.arg")),
|
||||||
|
) -> None:
|
||||||
|
"""Показать или установить базовую валюту модели"""
|
||||||
|
model = _load_model()
|
||||||
|
if currency:
|
||||||
|
model.base_currency = currency.upper()
|
||||||
|
_save_model(model)
|
||||||
|
ok = t("global.ok")
|
||||||
|
msg = t("cmd.config.base_currency.ok", currency=model.base_currency)
|
||||||
|
console.print(f"[green]{ok}[/green] {msg}")
|
||||||
|
else:
|
||||||
|
console.print(t("cmd.config.base_currency.show", currency=f"[bold]{model.base_currency}[/bold]"))
|
||||||
|
|
||||||
|
|
||||||
|
# ── rate ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@app.command()
|
||||||
|
def rate_set(
|
||||||
|
from_currency: str = typer.Argument(..., help=t("cmd.config.rate_set.arg.from")),
|
||||||
|
to_currency: str = typer.Argument(..., help=t("cmd.config.rate_set.arg.to")),
|
||||||
|
rate: float = typer.Argument(..., help=t("cmd.config.rate_set.arg.rate")),
|
||||||
|
) -> None:
|
||||||
|
"""Добавить или обновить курс валюты"""
|
||||||
|
model = _load_model()
|
||||||
|
fc, tc = from_currency.upper(), to_currency.upper()
|
||||||
|
existing = [r for r in model.exchange_rates if r.from_currency == fc and r.to_currency == tc]
|
||||||
|
if existing:
|
||||||
|
existing[0].rate = rate
|
||||||
|
else:
|
||||||
|
model.exchange_rates.append(ExchangeRate(
|
||||||
|
from_currency=from_currency.upper(),
|
||||||
|
to_currency=to_currency.upper(),
|
||||||
|
rate=rate,
|
||||||
|
))
|
||||||
|
_save_model(model)
|
||||||
|
fc, tc = from_currency.upper(), to_currency.upper()
|
||||||
|
console.print(f"[green]{t('global.ok')}[/green] {t('cmd.config.rate_set.ok', fc=fc, tc=tc, rate=rate)}")
|
||||||
|
|
||||||
|
|
||||||
|
@app.command(name="rate-list")
|
||||||
|
def rate_list() -> None:
|
||||||
|
"""Список курсов валют"""
|
||||||
|
model = _load_model()
|
||||||
|
if not model.exchange_rates:
|
||||||
|
console.print(f"[yellow]{t('cmd.config.rate_list.empty')}[/yellow]")
|
||||||
|
return
|
||||||
|
table = Table(title=t("table.rates.title"))
|
||||||
|
table.add_column(t("table.rates.col.from"), style="cyan")
|
||||||
|
table.add_column(t("table.rates.col.to"), style="cyan")
|
||||||
|
table.add_column(t("table.rates.col.rate"), justify="right")
|
||||||
|
for r in model.exchange_rates:
|
||||||
|
table.add_row(r.from_currency, r.to_currency, str(r.rate))
|
||||||
|
console.print(table)
|
||||||
|
|
||||||
|
|
||||||
|
@app.command(name="rate-remove")
|
||||||
|
def rate_remove(
|
||||||
|
from_currency: str = typer.Argument(..., help=t("cmd.config.rate_set.arg.from")),
|
||||||
|
to_currency: str = typer.Argument(..., help=t("cmd.config.rate_set.arg.to")),
|
||||||
|
) -> None:
|
||||||
|
"""Удалить курс валюты"""
|
||||||
|
model = _load_model()
|
||||||
|
fc, tc = from_currency.upper(), to_currency.upper()
|
||||||
|
initial = len(model.exchange_rates)
|
||||||
|
model.exchange_rates = [
|
||||||
|
r for r in model.exchange_rates
|
||||||
|
if not (r.from_currency == fc and r.to_currency == tc)
|
||||||
|
]
|
||||||
|
if len(model.exchange_rates) < initial:
|
||||||
|
_save_model(model)
|
||||||
|
console.print(f"[green]{t('global.ok')}[/green] {t('cmd.config.rate_remove.ok', fc=fc, tc=tc)}")
|
||||||
|
else:
|
||||||
|
console.print(f"[red]{t('cmd.config.rate_remove.err', fc=fc, tc=tc)}[/red]")
|
||||||
|
raise typer.Exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
# ── account ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@app.command()
|
||||||
|
def account_add(
|
||||||
|
name: str = typer.Option(..., "--name", "-n", help=t("cmd.config.account_add.opt.name")),
|
||||||
|
balance: float = typer.Option(0.0, "--balance", "-b", help=t("cmd.config.account_add.opt.balance")),
|
||||||
|
currency: str = typer.Option("", "--currency", "-c", help=t("cmd.config.account_add.opt.currency")),
|
||||||
|
) -> None:
|
||||||
|
"""Добавить счёт"""
|
||||||
|
model = _load_model()
|
||||||
|
curr = currency.upper() if currency else model.base_currency
|
||||||
|
account = Account(name=name, balance=balance, currency=curr)
|
||||||
|
model.accounts.append(account)
|
||||||
|
_save_model(model)
|
||||||
|
ok = t("global.ok")
|
||||||
|
msg = t("cmd.config.account_add.ok", name=name, balance=balance, currency=curr)
|
||||||
|
console.print(f"[green]{ok}[/green] {msg}")
|
||||||
|
|
||||||
|
|
||||||
|
@app.command(name="account-list")
|
||||||
|
def account_list() -> None:
|
||||||
|
"""Список счетов"""
|
||||||
|
model = _load_model()
|
||||||
|
if not model.accounts:
|
||||||
|
console.print(f"[yellow]{t('cmd.config.account_list.empty')}[/yellow]")
|
||||||
|
return
|
||||||
|
table = Table(title=t("table.accounts_config.title"))
|
||||||
|
table.add_column(t("table.accounts_config.col.id"), style="dim")
|
||||||
|
table.add_column(t("table.accounts_config.col.name"), style="cyan")
|
||||||
|
table.add_column(t("table.accounts_config.col.currency"))
|
||||||
|
table.add_column(t("table.accounts_config.col.balance"), justify="right")
|
||||||
|
for a in model.accounts:
|
||||||
|
table.add_row(str(a.id)[:8], a.name, a.currency, f"{a.balance:,.2f}")
|
||||||
|
console.print(table)
|
||||||
|
|
||||||
|
|
||||||
|
@app.command(name="account-remove")
|
||||||
|
def account_remove(
|
||||||
|
identifier: str = typer.Argument(..., help=t("cmd.config.account_remove.arg")),
|
||||||
|
) -> None:
|
||||||
|
"""Удалить счёт по ID или названию"""
|
||||||
|
model = _load_model()
|
||||||
|
initial = len(model.accounts)
|
||||||
|
model.accounts = [
|
||||||
|
a for a in model.accounts
|
||||||
|
if str(a.id) != identifier and a.name != identifier
|
||||||
|
]
|
||||||
|
if len(model.accounts) < initial:
|
||||||
|
_save_model(model)
|
||||||
|
console.print(f"[green]{t('global.ok')}[/green] {t('cmd.config.account_remove.ok', id=identifier)}")
|
||||||
|
else:
|
||||||
|
console.print(f"[red]{t('cmd.config.account_remove.err', id=identifier)}[/red]")
|
||||||
|
raise typer.Exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
# ── transaction ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@app.command()
|
||||||
|
def transaction_add(
|
||||||
|
account: str = typer.Option(..., "--account", "-a", help=t("cmd.config.transaction_add.opt.account")),
|
||||||
|
amount: float = typer.Option(..., "--amount", "-m", help=t("cmd.config.transaction_add.opt.amount")),
|
||||||
|
category: str = typer.Option("", "--category", "-c", help=t("cmd.config.transaction_add.opt.category")),
|
||||||
|
description: str = typer.Option("", "--description", "-d", help=t("cmd.config.transaction_add.opt.description")),
|
||||||
|
date: str = typer.Option("", "--date", "-D", help=t("cmd.config.transaction_add.opt.date")),
|
||||||
|
) -> None:
|
||||||
|
"""Добавить транзакцию"""
|
||||||
|
model = _load_model()
|
||||||
|
account_id = _resolve_account_id(model, account)
|
||||||
|
if not account_id:
|
||||||
|
console.print(f"[red]{t('cmd.config.transaction_add.err', account=account)}[/red]")
|
||||||
|
raise typer.Exit(1)
|
||||||
|
txn = Transaction(
|
||||||
|
date=date or "",
|
||||||
|
account=account_id,
|
||||||
|
category=category,
|
||||||
|
amount=amount,
|
||||||
|
description=description,
|
||||||
|
)
|
||||||
|
model.transactions.append(txn)
|
||||||
|
_save_model(model)
|
||||||
|
kind = t("cmd.config.transaction_add.kind.income") if amount >= 0 else t("cmd.config.transaction_add.kind.expense")
|
||||||
|
ok = t("global.ok")
|
||||||
|
msg = t("cmd.config.transaction_add.ok", kind=kind, amount=abs(amount), account=account)
|
||||||
|
console.print(f"[green]{ok}[/green] {msg}")
|
||||||
|
|
||||||
|
|
||||||
|
@app.command(name="transaction-list")
|
||||||
|
def transaction_list() -> None:
|
||||||
|
"""Список транзакций"""
|
||||||
|
model = _load_model()
|
||||||
|
if not model.transactions:
|
||||||
|
console.print(f"[yellow]{t('cmd.config.transaction_list.empty')}[/yellow]")
|
||||||
|
return
|
||||||
|
table = Table(title=t("table.transactions.title"))
|
||||||
|
table.add_column(t("table.accounts_config.col.id"), style="dim")
|
||||||
|
table.add_column(t("table.transactions.col.date"))
|
||||||
|
table.add_column(t("table.accounts_config.col.name"))
|
||||||
|
table.add_column(t("table.transactions.col.category"))
|
||||||
|
table.add_column(t("table.transactions.col.amount"), justify="right")
|
||||||
|
table.add_column(t("table.transactions.col.description"))
|
||||||
|
for tx in model.transactions:
|
||||||
|
account_name = _account_name(model, tx.account)
|
||||||
|
table.add_row(
|
||||||
|
str(tx.id)[:8], tx.date, account_name, tx.category,
|
||||||
|
f"{tx.amount:,.2f}", tx.description,
|
||||||
|
)
|
||||||
|
console.print(table)
|
||||||
|
|
||||||
|
|
||||||
|
@app.command(name="transaction-remove")
|
||||||
|
def transaction_remove(
|
||||||
|
identifier: str = typer.Argument(..., help=t("cmd.config.transaction_remove.arg")),
|
||||||
|
) -> None:
|
||||||
|
"""Удалить транзакцию по ID"""
|
||||||
|
model = _load_model()
|
||||||
|
initial = len(model.transactions)
|
||||||
|
model.transactions = [t for t in model.transactions if str(t.id) != identifier]
|
||||||
|
if len(model.transactions) < initial:
|
||||||
|
_save_model(model)
|
||||||
|
console.print(f"[green]{t('global.ok')}[/green] {t('cmd.config.transaction_remove.ok', id=identifier)}")
|
||||||
|
else:
|
||||||
|
console.print(f"[red]{t('cmd.config.transaction_remove.err', id=identifier)}[/red]")
|
||||||
|
raise typer.Exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
# ── recurring ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@app.command()
|
||||||
|
def recurring_add(
|
||||||
|
amount: float = typer.Option(..., "--amount", "-m", help=t("cmd.config.recurring_add.opt.amount")),
|
||||||
|
category: str = typer.Option("", "--category", "-c", help=t("cmd.config.recurring_add.opt.category")),
|
||||||
|
frequency: str = typer.Option("monthly", "--frequency", "-f", help=t("cmd.config.recurring_add.opt.frequency")),
|
||||||
|
start_date: str = typer.Option("", "--start", "-s", help=t("cmd.config.recurring_add.opt.start")),
|
||||||
|
end_date: str = typer.Option("", "--end", "-e", help=t("cmd.config.recurring_add.opt.end")),
|
||||||
|
) -> None:
|
||||||
|
"""Добавить регулярный платёж"""
|
||||||
|
model = _load_model()
|
||||||
|
rc = RecurringCashflow(
|
||||||
|
start_date=start_date or "", end_date=end_date or "",
|
||||||
|
frequency=frequency, amount=amount, category=category,
|
||||||
|
)
|
||||||
|
model.recurring.append(rc)
|
||||||
|
_save_model(model)
|
||||||
|
ok = t("global.ok")
|
||||||
|
msg = t("cmd.config.recurring_add.ok", amount=abs(amount), frequency=frequency)
|
||||||
|
console.print(f"[green]{ok}[/green] {msg}")
|
||||||
|
|
||||||
|
|
||||||
|
@app.command(name="recurring-list")
|
||||||
|
def recurring_list() -> None:
|
||||||
|
"""Список регулярных платежей"""
|
||||||
|
model = _load_model()
|
||||||
|
if not model.recurring:
|
||||||
|
console.print(f"[yellow]{t('cmd.config.recurring_list.empty')}[/yellow]")
|
||||||
|
return
|
||||||
|
table = Table(title=t("table.recurring.title"))
|
||||||
|
table.add_column(t("table.recurring.col.id"), style="dim")
|
||||||
|
table.add_column(t("table.recurring.col.start"))
|
||||||
|
table.add_column(t("table.recurring.col.end"))
|
||||||
|
table.add_column(t("table.recurring.col.period"))
|
||||||
|
table.add_column(t("table.recurring.col.amount"), justify="right")
|
||||||
|
table.add_column(t("table.recurring.col.category"))
|
||||||
|
for r in model.recurring:
|
||||||
|
row = (str(r.id)[:8], r.start_date, r.end_date, r.frequency, f"{r.amount:,.2f}", r.category)
|
||||||
|
table.add_row(*row)
|
||||||
|
console.print(table)
|
||||||
|
|
||||||
|
|
||||||
|
@app.command(name="recurring-remove")
|
||||||
|
def recurring_remove(
|
||||||
|
identifier: str = typer.Argument(..., help=t("cmd.config.recurring_remove.arg")),
|
||||||
|
) -> None:
|
||||||
|
"""Удалить регулярный платёж по ID"""
|
||||||
|
model = _load_model()
|
||||||
|
initial = len(model.recurring)
|
||||||
|
model.recurring = [r for r in model.recurring if str(r.id) != identifier]
|
||||||
|
if len(model.recurring) < initial:
|
||||||
|
_save_model(model)
|
||||||
|
console.print(f"[green]{t('global.ok')}[/green] {t('cmd.config.recurring_remove.ok', id=identifier)}")
|
||||||
|
else:
|
||||||
|
console.print(f"[red]{t('cmd.config.recurring_remove.err', id=identifier)}[/red]")
|
||||||
|
raise typer.Exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
# ── asset ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@app.command()
|
||||||
|
def asset_add(
|
||||||
|
name: str = typer.Option(..., "--name", "-n", help=t("cmd.config.asset_add.opt.name")),
|
||||||
|
value: float = typer.Option(0.0, "--value", "-v", help=t("cmd.config.asset_add.opt.value")),
|
||||||
|
growth_rate: float = typer.Option(0.0, "--growth", "-g", help=t("cmd.config.asset_add.opt.growth")),
|
||||||
|
) -> None:
|
||||||
|
"""Добавить актив"""
|
||||||
|
model = _load_model()
|
||||||
|
asset = Asset(name=name, value=value, growth_rate=growth_rate)
|
||||||
|
model.assets.append(asset)
|
||||||
|
_save_model(model)
|
||||||
|
console.print(f"[green]{t('global.ok')}[/green] {t('cmd.config.asset_add.ok', name=name, value=value)}")
|
||||||
|
|
||||||
|
|
||||||
|
@app.command(name="asset-list")
|
||||||
|
def asset_list() -> None:
|
||||||
|
"""Список активов"""
|
||||||
|
model = _load_model()
|
||||||
|
if not model.assets:
|
||||||
|
console.print(f"[yellow]{t('cmd.config.asset_list.empty')}[/yellow]")
|
||||||
|
return
|
||||||
|
table = Table(title=t("table.assets.title"))
|
||||||
|
table.add_column(t("table.assets.col.id"), style="dim")
|
||||||
|
table.add_column(t("table.assets.col.name"), style="cyan")
|
||||||
|
table.add_column(t("table.assets.col.value"), justify="right")
|
||||||
|
table.add_column(t("table.assets.col.growth"), justify="right")
|
||||||
|
for a in model.assets:
|
||||||
|
table.add_row(str(a.id)[:8], a.name, f"{a.value:,.2f}", f"{a.growth_rate:.1f}%")
|
||||||
|
console.print(table)
|
||||||
|
|
||||||
|
|
||||||
|
@app.command(name="asset-remove")
|
||||||
|
def asset_remove(
|
||||||
|
identifier: str = typer.Argument(..., help=t("cmd.config.asset_remove.arg")),
|
||||||
|
) -> None:
|
||||||
|
"""Удалить актив по ID или названию"""
|
||||||
|
model = _load_model()
|
||||||
|
initial = len(model.assets)
|
||||||
|
model.assets = [
|
||||||
|
a for a in model.assets
|
||||||
|
if str(a.id) != identifier and a.name != identifier
|
||||||
|
]
|
||||||
|
if len(model.assets) < initial:
|
||||||
|
_save_model(model)
|
||||||
|
console.print(f"[green]{t('global.ok')}[/green] {t('cmd.config.asset_remove.ok', id=identifier)}")
|
||||||
|
else:
|
||||||
|
console.print(f"[red]{t('cmd.config.asset_remove.err', id=identifier)}[/red]")
|
||||||
|
raise typer.Exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
# ── liability ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@app.command()
|
||||||
|
def liability_add(
|
||||||
|
name: str = typer.Option(..., "--name", "-n", help=t("cmd.config.liability_add.opt.name")),
|
||||||
|
balance: float = typer.Option(0.0, "--balance", "-b", help=t("cmd.config.liability_add.opt.balance")),
|
||||||
|
interest: float = typer.Option(0.0, "--interest", "-i", help=t("cmd.config.liability_add.opt.interest")),
|
||||||
|
payment: float = typer.Option(0.0, "--payment", "-p", help=t("cmd.config.liability_add.opt.payment")),
|
||||||
|
) -> None:
|
||||||
|
"""Добавить обязательство"""
|
||||||
|
model = _load_model()
|
||||||
|
liability = Liability(name=name, balance=balance, interest=interest, payment=payment)
|
||||||
|
model.liabilities.append(liability)
|
||||||
|
_save_model(model)
|
||||||
|
console.print(f"[green]{t('global.ok')}[/green] {t('cmd.config.liability_add.ok', name=name, balance=balance)}")
|
||||||
|
|
||||||
|
|
||||||
|
@app.command(name="liability-list")
|
||||||
|
def liability_list() -> None:
|
||||||
|
"""Список обязательств"""
|
||||||
|
model = _load_model()
|
||||||
|
if not model.liabilities:
|
||||||
|
console.print(f"[yellow]{t('cmd.config.liability_list.empty')}[/yellow]")
|
||||||
|
return
|
||||||
|
table = Table(title=t("table.liabilities.title"))
|
||||||
|
table.add_column(t("table.liabilities.col.id"), style="dim")
|
||||||
|
table.add_column(t("table.liabilities.col.name"), style="cyan")
|
||||||
|
table.add_column(t("table.liabilities.col.balance"), justify="right")
|
||||||
|
table.add_column(t("table.liabilities.col.rate"), justify="right")
|
||||||
|
table.add_column(t("table.liabilities.col.payment"), justify="right")
|
||||||
|
for li in model.liabilities:
|
||||||
|
b, i, p = f"{li.balance:,.2f}", f"{li.interest:.1f}%", f"{li.payment:,.2f}"
|
||||||
|
row = (str(li.id)[:8], li.name, b, i, p)
|
||||||
|
table.add_row(*row)
|
||||||
|
console.print(table)
|
||||||
|
|
||||||
|
|
||||||
|
@app.command(name="liability-remove")
|
||||||
|
def liability_remove(
|
||||||
|
identifier: str = typer.Argument(..., help=t("cmd.config.liability_remove.arg")),
|
||||||
|
) -> None:
|
||||||
|
"""Удалить обязательство по ID или названию"""
|
||||||
|
model = _load_model()
|
||||||
|
initial = len(model.liabilities)
|
||||||
|
model.liabilities = [
|
||||||
|
li for li in model.liabilities
|
||||||
|
if str(li.id) != identifier and li.name != identifier
|
||||||
|
]
|
||||||
|
if len(model.liabilities) < initial:
|
||||||
|
_save_model(model)
|
||||||
|
console.print(f"[green]{t('global.ok')}[/green] {t('cmd.config.liability_remove.ok', id=identifier)}")
|
||||||
|
else:
|
||||||
|
console.print(f"[red]{t('cmd.config.liability_remove.err', id=identifier)}[/red]")
|
||||||
|
raise typer.Exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
# ── helpers ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_account_id(model: FinancialModel, identifier: str) -> str | None:
|
||||||
|
for a in model.accounts:
|
||||||
|
if str(a.id) == identifier or a.name == identifier:
|
||||||
|
return str(a.id)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _account_name(model: FinancialModel, account_id: str) -> str:
|
||||||
|
for a in model.accounts:
|
||||||
|
if str(a.id) == account_id:
|
||||||
|
return a.name
|
||||||
|
return account_id[:8]
|
||||||
+350
@@ -0,0 +1,350 @@
|
|||||||
|
import os
|
||||||
|
|
||||||
|
_TRANSLATIONS: dict[str, dict[str, str]] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _r(key: str, text: str) -> str:
|
||||||
|
_TRANSLATIONS.setdefault("ru", {})[key] = text
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def _e(key: str, text: str) -> str:
|
||||||
|
_TRANSLATIONS.setdefault("en", {})[key] = text
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
# ── app ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_r("app.help", "CashFlow Forecast — личная финансовая модель")
|
||||||
|
|
||||||
|
# ── cmd.init ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_r("cmd.init.help", "Создать пустую финансовую модель")
|
||||||
|
_r("cmd.init.ok", "Пустая модель создана в {path}")
|
||||||
|
|
||||||
|
# ── cmd.forecast ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
_r("cmd.forecast.help", "Запустить прогноз денежных потоков")
|
||||||
|
_r("cmd.forecast.opt.months", "Количество месяцев прогноза")
|
||||||
|
_r("cmd.forecast.opt.currency", "Валюта отображения (по умолчанию — базовая валюта модели)")
|
||||||
|
_r("cmd.forecast.title", "Прогноз на {months} мес. ({currency})")
|
||||||
|
_r("cmd.forecast.col.account", "Счёт")
|
||||||
|
_r("cmd.forecast.col.month", "Месяц")
|
||||||
|
_r("cmd.forecast.col.balance", "Баланс")
|
||||||
|
_r("cmd.forecast.col.income", "Доход")
|
||||||
|
_r("cmd.forecast.col.expenses", "Расход")
|
||||||
|
_r("cmd.forecast.total", "Итог: Баланс: {balance} | Доход: {income} | Расход: {expenses}")
|
||||||
|
|
||||||
|
# ── cmd.scenario ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
_r("cmd.scenario.help", "Применить сценарий и показать прогноз")
|
||||||
|
_r("cmd.scenario.arg.name", "Имя сценария: baseline, optimistic, pessimistic")
|
||||||
|
_r("cmd.scenario.opt.months", "Количество месяцев")
|
||||||
|
_r("cmd.scenario.err.unknown", "Неизвестный сценарий: {name}")
|
||||||
|
_r("cmd.scenario.err.available", "Доступны: {scenarios}")
|
||||||
|
_r("cmd.scenario.label", "Сценарий")
|
||||||
|
_r("cmd.scenario.label.balance", "Баланс")
|
||||||
|
_r("cmd.scenario.label.income", "Доход")
|
||||||
|
_r("cmd.scenario.label.expenses", "Расход")
|
||||||
|
|
||||||
|
# ── cmd.whatif ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_r("cmd.whatif.help", "What-if анализ с произвольными множителями")
|
||||||
|
_r("cmd.whatif.opt.income", "Множитель дохода")
|
||||||
|
_r("cmd.whatif.opt.expense", "Множитель расхода")
|
||||||
|
_r("cmd.whatif.opt.growth", "Множитель роста активов")
|
||||||
|
_r("cmd.whatif.opt.months", "Количество месяцев")
|
||||||
|
_r("cmd.whatif.title", "What-if анализ")
|
||||||
|
_r("cmd.whatif.params", "Доход x{income} | Расход x{expense} | Рост x{growth}")
|
||||||
|
|
||||||
|
# ── cmd.compare ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
_r("cmd.compare.help", "Сравнить все сценарии")
|
||||||
|
_r("cmd.compare.opt.months", "Количество месяцев")
|
||||||
|
_r("cmd.compare.title", "Сравнение сценариев ({currency})")
|
||||||
|
_r("cmd.compare.col.scenario", "Сценарий")
|
||||||
|
|
||||||
|
# ── cmd.import ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_r("cmd.import.help", "Импорт данных из Excel")
|
||||||
|
_r("cmd.import.arg.path", "Путь к .xlsx файлу")
|
||||||
|
_r("cmd.import.ok",
|
||||||
|
"Импортировано: {accounts} счетов, {transactions} транзакций, "
|
||||||
|
"{recurring} регулярных платежей, {assets} активов, {liabilities} обязательств")
|
||||||
|
_r("cmd.import.base_currency", "Базовая валюта: {currency}")
|
||||||
|
_r("cmd.import.err", "Ошибка импорта: {error}")
|
||||||
|
|
||||||
|
# ── cmd.export ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_r("cmd.export.help", "Экспорт модели в Excel")
|
||||||
|
_r("cmd.export.arg.path", "Путь для .xlsx файла")
|
||||||
|
_r("cmd.export.ok", "Модель экспортирована в {path}")
|
||||||
|
_r("cmd.export.err", "Ошибка экспорта: {error}")
|
||||||
|
|
||||||
|
# ── cmd.analyze ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
_r("cmd.analyze.help", "AI-анализ финансовой модели")
|
||||||
|
_r("cmd.analyze.opt.months", "Количество месяцев для анализа")
|
||||||
|
_r("cmd.analyze.title.prompt", "Промпт для AI")
|
||||||
|
_r("cmd.analyze.title.summary", "Сводка")
|
||||||
|
_r("cmd.analyze.base_currency", "Базовая валюта: {currency}")
|
||||||
|
_r("cmd.analyze.stub", "AI-ответ: заглушка. Подключите реальный API в ai/assistant.py")
|
||||||
|
|
||||||
|
# ── cmd.info ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_r("cmd.info.help", "Сводка всей финансовой модели")
|
||||||
|
_r("cmd.info.metadata", "Метаданные")
|
||||||
|
_r("cmd.info.base_currency", "Базовая валюта: {currency} ({symbol})")
|
||||||
|
_r("cmd.info.accounts", "Счетов: {count}")
|
||||||
|
_r("cmd.info.transactions", "Транзакций: {count}")
|
||||||
|
_r("cmd.info.recurring", "Регулярных платежей: {count}")
|
||||||
|
_r("cmd.info.assets", "Активов: {count}")
|
||||||
|
_r("cmd.info.liabilities", "Обязательств: {count}")
|
||||||
|
_r("cmd.info.rates", "Курсов валют: {count}")
|
||||||
|
_r("cmd.info.more", "... и ещё {count} транзакций")
|
||||||
|
|
||||||
|
# ── table headers ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
_r("table.accounts.title", "Счета")
|
||||||
|
_r("table.accounts_config.title", "Счета")
|
||||||
|
_r("table.accounts.col.name", "Название")
|
||||||
|
_r("table.accounts.col.currency", "Валюта")
|
||||||
|
_r("table.accounts.col.balance", "Баланс")
|
||||||
|
|
||||||
|
_r("table.transactions.title", "Транзакции")
|
||||||
|
_r("table.transactions.col.date", "Дата")
|
||||||
|
_r("table.transactions.col.category", "Категория")
|
||||||
|
_r("table.transactions.col.amount", "Сумма")
|
||||||
|
_r("table.transactions.col.description", "Описание")
|
||||||
|
|
||||||
|
_r("table.recurring.title", "Регулярные платежи")
|
||||||
|
_r("table.recurring.col.period", "Период")
|
||||||
|
_r("table.recurring.col.amount", "Сумма")
|
||||||
|
_r("table.recurring.col.category", "Категория")
|
||||||
|
_r("table.recurring.col.id", "ID")
|
||||||
|
_r("table.recurring.col.start", "Начало")
|
||||||
|
_r("table.recurring.col.end", "Конец")
|
||||||
|
|
||||||
|
_r("table.assets.title", "Активы")
|
||||||
|
_r("table.assets.col.id", "ID")
|
||||||
|
_r("table.assets.col.name", "Название")
|
||||||
|
_r("table.assets.col.value", "Стоимость")
|
||||||
|
_r("table.assets.col.growth", "Рост (%)")
|
||||||
|
|
||||||
|
_r("table.liabilities.title", "Обязательства")
|
||||||
|
_r("table.liabilities.col.id", "ID")
|
||||||
|
_r("table.liabilities.col.name", "Название")
|
||||||
|
_r("table.liabilities.col.balance", "Долг")
|
||||||
|
_r("table.liabilities.col.rate", "Ставка")
|
||||||
|
_r("table.liabilities.col.payment", "Платёж")
|
||||||
|
|
||||||
|
_r("table.rates.title", "Курсы валют")
|
||||||
|
_r("table.rates.col.from", "Из")
|
||||||
|
_r("table.rates.col.to", "В")
|
||||||
|
_r("table.rates.col.rate", "Курс")
|
||||||
|
|
||||||
|
_r("table.accounts_config.col.id", "ID")
|
||||||
|
_r("table.accounts_config.col.name", "Название")
|
||||||
|
_r("table.accounts_config.col.currency", "Валюта")
|
||||||
|
_r("table.accounts_config.col.balance", "Баланс")
|
||||||
|
|
||||||
|
# ── config ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_r("config.help", "Управление моделью (счета, транзакции, валюты...)")
|
||||||
|
|
||||||
|
_r("cmd.config.base_currency.help", "Показать или установить базовую валюту модели")
|
||||||
|
_r("cmd.config.base_currency.arg", "Код валюты (RUB, USD, EUR...)")
|
||||||
|
_r("cmd.config.base_currency.ok", "Базовая валюта установлена: {currency}")
|
||||||
|
_r("cmd.config.base_currency.show", "Базовая валюта: {currency}")
|
||||||
|
|
||||||
|
_r("cmd.config.rate_set.help", "Добавить или обновить курс валюты")
|
||||||
|
_r("cmd.config.rate_set.arg.from", "Из валюты")
|
||||||
|
_r("cmd.config.rate_set.arg.to", "В валюту")
|
||||||
|
_r("cmd.config.rate_set.arg.rate", "Курс")
|
||||||
|
_r("cmd.config.rate_set.ok", "Курс {fc} → {tc} = {rate}")
|
||||||
|
|
||||||
|
_r("cmd.config.rate_list.help", "Список курсов валют")
|
||||||
|
_r("cmd.config.rate_list.empty", "Курсы не заданы")
|
||||||
|
|
||||||
|
_r("cmd.config.rate_remove.help", "Удалить курс валюты")
|
||||||
|
_r("cmd.config.rate_remove.ok", "Курс {fc} → {tc} удалён")
|
||||||
|
_r("cmd.config.rate_remove.err", "Курс {fc} → {tc} не найден")
|
||||||
|
|
||||||
|
_r("cmd.config.account_add.help", "Добавить счёт")
|
||||||
|
_r("cmd.config.account_add.opt.name", "Название счёта")
|
||||||
|
_r("cmd.config.account_add.opt.balance", "Начальный баланс")
|
||||||
|
_r("cmd.config.account_add.opt.currency", "Валюта счёта")
|
||||||
|
_r("cmd.config.account_add.ok", "Счёт '{name}' добавлен (баланс: {balance}, валюта: {currency})")
|
||||||
|
|
||||||
|
_r("cmd.config.account_list.help", "Список счетов")
|
||||||
|
_r("cmd.config.account_list.empty", "Счета не добавлены")
|
||||||
|
|
||||||
|
_r("cmd.config.account_remove.help", "Удалить счёт по ID или названию")
|
||||||
|
_r("cmd.config.account_remove.arg", "ID или название счёта")
|
||||||
|
_r("cmd.config.account_remove.ok", "Счёт '{id}' удалён")
|
||||||
|
_r("cmd.config.account_remove.err", "Счёт '{id}' не найден")
|
||||||
|
|
||||||
|
_r("cmd.config.transaction_add.help", "Добавить транзакцию")
|
||||||
|
_r("cmd.config.transaction_add.opt.account", "ID или название счёта")
|
||||||
|
_r("cmd.config.transaction_add.opt.amount", "Сумма (доход/расход)")
|
||||||
|
_r("cmd.config.transaction_add.opt.category", "Категория")
|
||||||
|
_r("cmd.config.transaction_add.opt.description", "Описание")
|
||||||
|
_r("cmd.config.transaction_add.opt.date", "Дата (YYYY-MM-DD)")
|
||||||
|
_r("cmd.config.transaction_add.ok", "Транзакция '{kind}' на {amount:.2f} (счёт: {account})")
|
||||||
|
_r("cmd.config.transaction_add.kind.income", "доход")
|
||||||
|
_r("cmd.config.transaction_add.kind.expense", "расход")
|
||||||
|
_r("cmd.config.transaction_add.err", "Счёт '{account}' не найден")
|
||||||
|
|
||||||
|
_r("cmd.config.transaction_list.help", "Список транзакций")
|
||||||
|
_r("cmd.config.transaction_list.empty", "Транзакции не добавлены")
|
||||||
|
|
||||||
|
_r("cmd.config.transaction_remove.help", "Удалить транзакцию по ID")
|
||||||
|
_r("cmd.config.transaction_remove.arg", "ID транзакции")
|
||||||
|
_r("cmd.config.transaction_remove.ok", "Транзакция '{id}' удалена")
|
||||||
|
_r("cmd.config.transaction_remove.err", "Транзакция '{id}' не найдена")
|
||||||
|
|
||||||
|
_r("cmd.config.recurring_add.help", "Добавить регулярный платёж")
|
||||||
|
_r("cmd.config.recurring_add.opt.amount", "Сумма")
|
||||||
|
_r("cmd.config.recurring_add.opt.category", "Категория")
|
||||||
|
_r("cmd.config.recurring_add.opt.frequency", "Периодичность")
|
||||||
|
_r("cmd.config.recurring_add.opt.start", "Дата начала (YYYY-MM-DD)")
|
||||||
|
_r("cmd.config.recurring_add.opt.end", "Дата окончания (YYYY-MM-DD)")
|
||||||
|
_r("cmd.config.recurring_add.ok", "Регулярный платёж на {amount:.2f} ({frequency})")
|
||||||
|
|
||||||
|
_r("cmd.config.recurring_list.help", "Список регулярных платежей")
|
||||||
|
_r("cmd.config.recurring_list.empty", "Регулярные платежи не добавлены")
|
||||||
|
|
||||||
|
_r("cmd.config.recurring_remove.help", "Удалить регулярный платёж по ID")
|
||||||
|
_r("cmd.config.recurring_remove.arg", "ID регулярного платежа")
|
||||||
|
_r("cmd.config.recurring_remove.ok", "Регулярный платёж '{id}' удалён")
|
||||||
|
_r("cmd.config.recurring_remove.err", "Регулярный платёж '{id}' не найден")
|
||||||
|
|
||||||
|
_r("cmd.config.asset_add.help", "Добавить актив")
|
||||||
|
_r("cmd.config.asset_add.opt.name", "Название актива")
|
||||||
|
_r("cmd.config.asset_add.opt.value", "Стоимость")
|
||||||
|
_r("cmd.config.asset_add.opt.growth", "Годовой рост (%)")
|
||||||
|
_r("cmd.config.asset_add.ok", "Актив '{name}' добавлен (стоимость: {value})")
|
||||||
|
|
||||||
|
_r("cmd.config.asset_list.help", "Список активов")
|
||||||
|
_r("cmd.config.asset_list.empty", "Активы не добавлены")
|
||||||
|
|
||||||
|
_r("cmd.config.asset_remove.help", "Удалить актив по ID или названию")
|
||||||
|
_r("cmd.config.asset_remove.arg", "ID или название актива")
|
||||||
|
_r("cmd.config.asset_remove.ok", "Актив '{id}' удалён")
|
||||||
|
_r("cmd.config.asset_remove.err", "Актив '{id}' не найден")
|
||||||
|
|
||||||
|
_r("cmd.config.liability_add.help", "Добавить обязательство")
|
||||||
|
_r("cmd.config.liability_add.opt.name", "Название")
|
||||||
|
_r("cmd.config.liability_add.opt.balance", "Остаток долга")
|
||||||
|
_r("cmd.config.liability_add.opt.interest", "Процентная ставка (%)")
|
||||||
|
_r("cmd.config.liability_add.opt.payment", "Ежемесячный платёж")
|
||||||
|
_r("cmd.config.liability_add.ok", "Обязательство '{name}' добавлено (долг: {balance})")
|
||||||
|
|
||||||
|
_r("cmd.config.liability_list.help", "Список обязательств")
|
||||||
|
_r("cmd.config.liability_list.empty", "Обязательства не добавлены")
|
||||||
|
|
||||||
|
_r("cmd.config.liability_remove.help", "Удалить обязательство по ID или названию")
|
||||||
|
_r("cmd.config.liability_remove.arg", "ID или название обязательства")
|
||||||
|
_r("cmd.config.liability_remove.ok", "Обязательство '{id}' удалено")
|
||||||
|
_r("cmd.config.liability_remove.err", "Обязательство '{id}' не найдено")
|
||||||
|
|
||||||
|
# ── global ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_r("global.ok", "OK")
|
||||||
|
|
||||||
|
# ── prompts ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_r("prompt.analyze", (
|
||||||
|
"Ты — финансовый AI-ассистент. Проанализируй финансовую модель пользователя.\n"
|
||||||
|
"\n"
|
||||||
|
"### Модель (JSON):\n"
|
||||||
|
"{model_json}\n"
|
||||||
|
"\n"
|
||||||
|
"### Прогноз на {months} месяцев:\n"
|
||||||
|
"{forecast_json}\n"
|
||||||
|
"\n"
|
||||||
|
"### Валюта:\n"
|
||||||
|
"Базовая валюта модели: {base_currency}\n"
|
||||||
|
"Отображаемая валюта: {display_currency}\n"
|
||||||
|
"\n"
|
||||||
|
"Дай анализ по пунктам:\n"
|
||||||
|
"1. Общее финансовое состояние\n"
|
||||||
|
"2. Тренд денежного потока (рост/падение)\n"
|
||||||
|
"3. Достаточность ликвидности\n"
|
||||||
|
"4. Рекомендации по улучшению"
|
||||||
|
))
|
||||||
|
|
||||||
|
_r("prompt.advice", (
|
||||||
|
"Ты — финансовый AI-ассистент. Дай персональные рекомендации.\n"
|
||||||
|
"\n"
|
||||||
|
"### Модель:\n"
|
||||||
|
"{model_json}\n"
|
||||||
|
"\n"
|
||||||
|
"### Прогноз:\n"
|
||||||
|
"{forecast_json}\n"
|
||||||
|
"\n"
|
||||||
|
"### Валюта:\n"
|
||||||
|
"Базовая валюта модели: {base_currency}\n"
|
||||||
|
"Отображаемая валюта: {display_currency}\n"
|
||||||
|
"\n"
|
||||||
|
"Вопрос пользователя: {question}\n"
|
||||||
|
"\n"
|
||||||
|
"Ответь как опытный финансовый консультант."
|
||||||
|
))
|
||||||
|
|
||||||
|
_r("prompt.scenario_comparison", (
|
||||||
|
"Ты — финансовый AI-ассистент. Сравни сценарии прогноза.\n"
|
||||||
|
"\n"
|
||||||
|
"### Результаты сценариев:\n"
|
||||||
|
"{scenarios_json}\n"
|
||||||
|
"\n"
|
||||||
|
"### Валюта:\n"
|
||||||
|
"Базовая валюта модели: {base_currency}\n"
|
||||||
|
"Отображаемая валюта: {display_currency}\n"
|
||||||
|
"\n"
|
||||||
|
"Дай рекомендацию: какой сценарий наиболее вероятен и почему."
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
|
_current_lang = "ru"
|
||||||
|
|
||||||
|
|
||||||
|
class Translator:
|
||||||
|
def __init__(self, lang: str | None = None):
|
||||||
|
self.lang = lang or _current_lang
|
||||||
|
if self.lang not in _TRANSLATIONS:
|
||||||
|
_TRANSLATIONS[self.lang] = {}
|
||||||
|
|
||||||
|
def t(self, key: str, **kwargs) -> str:
|
||||||
|
src = _TRANSLATIONS.get(self.lang) or _TRANSLATIONS.get("ru") or {}
|
||||||
|
if key in src:
|
||||||
|
template = src[key]
|
||||||
|
elif "ru" in _TRANSLATIONS and key in _TRANSLATIONS["ru"]:
|
||||||
|
template = _TRANSLATIONS["ru"][key]
|
||||||
|
else:
|
||||||
|
return key
|
||||||
|
return template.format(**kwargs) if kwargs else template
|
||||||
|
|
||||||
|
|
||||||
|
_GLOBAL = Translator()
|
||||||
|
|
||||||
|
|
||||||
|
def setup_i18n() -> None:
|
||||||
|
lang = os.environ.get("CF_LANG", "ru")
|
||||||
|
if lang not in _TRANSLATIONS:
|
||||||
|
_TRANSLATIONS[lang] = {}
|
||||||
|
_GLOBAL.lang = lang
|
||||||
|
|
||||||
|
|
||||||
|
def set_lang(lang: str) -> None:
|
||||||
|
if lang not in _TRANSLATIONS:
|
||||||
|
_TRANSLATIONS[lang] = {}
|
||||||
|
_GLOBAL.lang = lang
|
||||||
|
|
||||||
|
|
||||||
|
def get_lang() -> str:
|
||||||
|
return _GLOBAL.lang
|
||||||
|
|
||||||
|
|
||||||
|
def t(key: str, **kwargs) -> str:
|
||||||
|
return _GLOBAL.t(key, **kwargs)
|
||||||
+195
-66
@@ -6,7 +6,9 @@ from rich.console import Console
|
|||||||
from rich.table import Table
|
from rich.table import Table
|
||||||
|
|
||||||
from ai.assistant import AssistantService
|
from ai.assistant import AssistantService
|
||||||
from cashflow_model import FinancialModel
|
from cashflow_model import CurrencyConverter, FinancialModel
|
||||||
|
from cli.config import app as config_app
|
||||||
|
from cli.i18n import setup_i18n, t
|
||||||
from engine.forecast import ForecastService
|
from engine.forecast import ForecastService
|
||||||
from engine.scenarios import DEFAULT_SCENARIOS, ScenarioService
|
from engine.scenarios import DEFAULT_SCENARIOS, ScenarioService
|
||||||
from sync.excel_sync import ExcelSync
|
from sync.excel_sync import ExcelSync
|
||||||
@@ -16,12 +18,16 @@ try:
|
|||||||
except (AttributeError, OSError):
|
except (AttributeError, OSError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
app = typer.Typer(name="cf", help="CashFlow Forecast - personal finance model")
|
setup_i18n()
|
||||||
|
|
||||||
|
app = typer.Typer(name="cf", help=t("app.help"))
|
||||||
console = Console()
|
console = Console()
|
||||||
|
|
||||||
DATA_DIR = Path("data")
|
DATA_DIR = Path("data")
|
||||||
MODEL_PATH = DATA_DIR / "model.json"
|
MODEL_PATH = DATA_DIR / "model.json"
|
||||||
|
|
||||||
|
CURRENCY_OPTION = typer.Option(None, "--currency", "-c", help=t("cmd.forecast.opt.currency"))
|
||||||
|
|
||||||
|
|
||||||
def _load_model() -> FinancialModel:
|
def _load_model() -> FinancialModel:
|
||||||
if MODEL_PATH.exists():
|
if MODEL_PATH.exists():
|
||||||
@@ -33,169 +39,292 @@ def _save_model(model: FinancialModel) -> None:
|
|||||||
model.save(MODEL_PATH)
|
model.save(MODEL_PATH)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_converter(model: FinancialModel) -> CurrencyConverter:
|
||||||
|
return CurrencyConverter(model.exchange_rates)
|
||||||
|
|
||||||
|
|
||||||
|
def _fmt(amount: float, currency: str, symbol: str) -> str:
|
||||||
|
return f"{symbol}{amount:,.2f}"
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_currency(model: FinancialModel, currency: str | None) -> tuple[str, str]:
|
||||||
|
target = currency or model.base_currency
|
||||||
|
converter = _get_converter(model)
|
||||||
|
symbol = converter.get_symbol(target)
|
||||||
|
return target, symbol
|
||||||
|
|
||||||
|
|
||||||
|
def _convert_value(
|
||||||
|
converter: CurrencyConverter, amount: float, from_curr: str, to_curr: str
|
||||||
|
) -> float:
|
||||||
|
if to_curr == from_curr:
|
||||||
|
return amount
|
||||||
|
return converter.convert(amount, from_curr, to_curr)
|
||||||
|
|
||||||
|
|
||||||
@app.command()
|
@app.command()
|
||||||
def init() -> None:
|
def init() -> None:
|
||||||
"""Создать пустую финансовую модель"""
|
"""Создать пустую финансовую модель"""
|
||||||
model = FinancialModel()
|
model = FinancialModel()
|
||||||
_save_model(model)
|
_save_model(model)
|
||||||
console.print("[green]OK[/green] Пустая модель создана в data/model.json")
|
console.print(t("cmd.init.ok", path=str(MODEL_PATH)))
|
||||||
|
|
||||||
|
|
||||||
@app.command()
|
@app.command()
|
||||||
def forecast(
|
def forecast(
|
||||||
months: int = typer.Option(12, "--months", "-m", help="Количество месяцев прогноза"),
|
months: int = typer.Option(12, "--months", "-m", help=t("cmd.forecast.opt.months")),
|
||||||
|
currency: str | None = CURRENCY_OPTION,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Запустить прогноз денежных потоков"""
|
"""Запустить прогноз денежных потоков"""
|
||||||
model = _load_model()
|
model = _load_model()
|
||||||
|
target_curr, symbol = _resolve_currency(model, currency)
|
||||||
|
converter = _get_converter(model)
|
||||||
|
base_curr = model.base_currency
|
||||||
service = ForecastService(model)
|
service = ForecastService(model)
|
||||||
results = service.forecast_cashflow(months)
|
results = service.forecast_cashflow(months)
|
||||||
summary = service.summary(months)
|
summary = service.summary(months)
|
||||||
|
|
||||||
|
def _cv(val):
|
||||||
|
return _convert_value(converter, val, base_curr, target_curr)
|
||||||
|
|
||||||
|
def _f(val):
|
||||||
|
return _fmt(val, target_curr, symbol)
|
||||||
|
|
||||||
if results:
|
if results:
|
||||||
table = Table(title=f"Прогноз на {months} мес.")
|
table = Table(title=t("cmd.forecast.title", months=months, currency=target_curr))
|
||||||
table.add_column("Счёт", style="cyan")
|
table.add_column(t("cmd.forecast.col.account"), style="cyan")
|
||||||
table.add_column("Месяц", style="white")
|
table.add_column(t("cmd.forecast.col.month"), style="white")
|
||||||
table.add_column("Баланс", justify="right", style="green")
|
table.add_column(t("cmd.forecast.col.balance"), justify="right", style="green")
|
||||||
table.add_column("Доход", justify="right")
|
table.add_column(t("cmd.forecast.col.income"), justify="right")
|
||||||
table.add_column("Расход", justify="right")
|
table.add_column(t("cmd.forecast.col.expenses"), justify="right")
|
||||||
|
|
||||||
for r in results:
|
for r in results:
|
||||||
table.add_row(
|
table.add_row(
|
||||||
r["account"], str(r["month"]),
|
r["account"], str(r["month"]),
|
||||||
f"${r['balance']:.2f}",
|
_f(_cv(r["balance"])),
|
||||||
f"${r['income']:.2f}",
|
_f(_cv(r["income"])),
|
||||||
f"${r['expenses']:.2f}",
|
_f(_cv(r["expenses"])),
|
||||||
)
|
)
|
||||||
console.print(table)
|
console.print(table)
|
||||||
|
|
||||||
console.print(f"\n[bold]Итог:[/bold] Баланс: ${summary['total_balance']:.2f} | "
|
cb = _cv(summary["total_balance"])
|
||||||
f"Доход: ${summary['total_income']:.2f} | "
|
ci = _cv(summary["total_income"])
|
||||||
f"Расход: ${summary['total_expenses']:.2f}")
|
ce = _cv(summary["total_expenses"])
|
||||||
|
console.print(t("cmd.forecast.total", balance=_f(cb), income=_f(ci), expenses=_f(ce)))
|
||||||
|
|
||||||
|
|
||||||
@app.command()
|
@app.command()
|
||||||
def scenario(
|
def scenario(
|
||||||
name: str = typer.Argument("baseline", help="Имя сценария: baseline, optimistic, pessimistic"),
|
name: str = typer.Argument("baseline", help=t("cmd.scenario.arg.name")),
|
||||||
months: int = typer.Option(12, "--months", "-m", help="Количество месяцев"),
|
months: int = typer.Option(12, "--months", "-m", help=t("cmd.scenario.opt.months")),
|
||||||
|
currency: str | None = CURRENCY_OPTION,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Применить сценарий и показать прогноз"""
|
"""Применить сценарий и показать прогноз"""
|
||||||
model = _load_model()
|
model = _load_model()
|
||||||
|
target_curr, symbol = _resolve_currency(model, currency)
|
||||||
|
converter = _get_converter(model)
|
||||||
|
base_curr = model.base_currency
|
||||||
service = ScenarioService(model)
|
service = ScenarioService(model)
|
||||||
|
|
||||||
if name in DEFAULT_SCENARIOS:
|
if name in DEFAULT_SCENARIOS:
|
||||||
scenario_obj = DEFAULT_SCENARIOS[name]
|
scenario_obj = DEFAULT_SCENARIOS[name]
|
||||||
else:
|
else:
|
||||||
console.print(f"[red]Неизвестный сценарий: {name}[/red]")
|
console.print(f"[red]{t('cmd.scenario.err.unknown', name=name)}[/red]")
|
||||||
console.print(f"Доступны: {', '.join(DEFAULT_SCENARIOS.keys())}")
|
console.print(t("cmd.scenario.err.available", scenarios=", ".join(DEFAULT_SCENARIOS.keys())))
|
||||||
raise typer.Exit(1)
|
raise typer.Exit(1)
|
||||||
|
|
||||||
result = service.apply(scenario_obj, months)
|
result = service.apply(scenario_obj, months)
|
||||||
console.print(f"[bold]Сценарий:[/bold] {result['scenario']}")
|
balance = _convert_value(converter, result["total_balance"], base_curr, target_curr)
|
||||||
|
income = _convert_value(converter, result["total_income"], base_curr, target_curr)
|
||||||
|
expenses = _convert_value(converter, result["total_expenses"], base_curr, target_curr)
|
||||||
|
console.print(f"[bold]{t('cmd.scenario.label')}:[/bold] {result['scenario']}")
|
||||||
console.print(f"[dim]{result['scenario_description']}[/dim]")
|
console.print(f"[dim]{result['scenario_description']}[/dim]")
|
||||||
console.print(f"Баланс: ${result['total_balance']:.2f}")
|
console.print(f"{t('cmd.scenario.label.balance')}: {_fmt(balance, target_curr, symbol)}")
|
||||||
console.print(f"Доход: ${result['total_income']:.2f}")
|
console.print(f"{t('cmd.scenario.label.income')}: {_fmt(income, target_curr, symbol)}")
|
||||||
console.print(f"Расход: ${result['total_expenses']:.2f}")
|
console.print(f"{t('cmd.scenario.label.expenses')}: {_fmt(expenses, target_curr, symbol)}")
|
||||||
|
|
||||||
|
|
||||||
@app.command()
|
@app.command()
|
||||||
def whatif(
|
def whatif(
|
||||||
income_mult: float = typer.Option(1.0, "--income", "-i", help="Множитель дохода"),
|
income_mult: float = typer.Option(1.0, "--income", "-i", help=t("cmd.whatif.opt.income")),
|
||||||
expense_mult: float = typer.Option(1.0, "--expense", "-e", help="Множитель расхода"),
|
expense_mult: float = typer.Option(1.0, "--expense", "-e", help=t("cmd.whatif.opt.expense")),
|
||||||
growth_mult: float = typer.Option(1.0, "--growth", "-g", help="Множитель роста активов"),
|
growth_mult: float = typer.Option(1.0, "--growth", "-g", help=t("cmd.whatif.opt.growth")),
|
||||||
months: int = typer.Option(12, "--months", "-m", help="Количество месяцев"),
|
months: int = typer.Option(12, "--months", "-m", help=t("cmd.whatif.opt.months")),
|
||||||
|
currency: str | None = CURRENCY_OPTION,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""What-if анализ с произвольными множителями"""
|
"""What-if анализ с произвольными множителями"""
|
||||||
model = _load_model()
|
model = _load_model()
|
||||||
|
target_curr, symbol = _resolve_currency(model, currency)
|
||||||
|
converter = _get_converter(model)
|
||||||
|
base_curr = model.base_currency
|
||||||
service = ScenarioService(model)
|
service = ScenarioService(model)
|
||||||
result = service.what_if(income_mult, expense_mult, growth_mult, months)
|
result = service.what_if(income_mult, expense_mult, growth_mult, months)
|
||||||
|
|
||||||
console.print("[bold]What-if анализ[/bold]")
|
balance = _convert_value(converter, result["total_balance"], base_curr, target_curr)
|
||||||
console.print(f"Доход x{income_mult} | Расход x{expense_mult} | Рост x{growth_mult}")
|
income = _convert_value(converter, result["total_income"], base_curr, target_curr)
|
||||||
console.print(f"Баланс: ${result['total_balance']:.2f}")
|
expenses = _convert_value(converter, result["total_expenses"], base_curr, target_curr)
|
||||||
console.print(f"Доход: ${result['total_income']:.2f}")
|
console.print(f"[bold]{t('cmd.whatif.title')}[/bold]")
|
||||||
console.print(f"Расход: ${result['total_expenses']:.2f}")
|
console.print(t("cmd.whatif.params", income=income_mult, expense=expense_mult, growth=growth_mult))
|
||||||
|
console.print(f"{t('cmd.scenario.label.balance')}: {_fmt(balance, target_curr, symbol)}")
|
||||||
|
console.print(f"{t('cmd.scenario.label.income')}: {_fmt(income, target_curr, symbol)}")
|
||||||
|
console.print(f"{t('cmd.scenario.label.expenses')}: {_fmt(expenses, target_curr, symbol)}")
|
||||||
|
|
||||||
|
|
||||||
@app.command()
|
@app.command()
|
||||||
def compare(
|
def compare(
|
||||||
months: int = typer.Option(12, "--months", "-m", help="Количество месяцев"),
|
months: int = typer.Option(12, "--months", "-m", help=t("cmd.compare.opt.months")),
|
||||||
|
currency: str | None = CURRENCY_OPTION,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Сравнить все сценарии"""
|
"""Сравнить все сценарии"""
|
||||||
model = _load_model()
|
model = _load_model()
|
||||||
|
target_curr, symbol = _resolve_currency(model, currency)
|
||||||
|
converter = _get_converter(model)
|
||||||
|
base_curr = model.base_currency
|
||||||
service = ScenarioService(model)
|
service = ScenarioService(model)
|
||||||
results = service.compare(months)
|
results = service.compare(months)
|
||||||
|
|
||||||
table = Table(title="Сравнение сценариев")
|
table = Table(title=t("cmd.compare.title", currency=target_curr))
|
||||||
table.add_column("Сценарий", style="cyan")
|
table.add_column(t("cmd.compare.col.scenario"), style="cyan")
|
||||||
table.add_column("Баланс", justify="right")
|
table.add_column(t("cmd.forecast.col.balance"), justify="right")
|
||||||
table.add_column("Доход", justify="right")
|
table.add_column(t("cmd.forecast.col.income"), justify="right")
|
||||||
table.add_column("Расход", justify="right")
|
table.add_column(t("cmd.forecast.col.expenses"), justify="right")
|
||||||
|
|
||||||
for name, r in results.items():
|
for name, r in results.items():
|
||||||
table.add_row(
|
b = _convert_value(converter, r["total_balance"], base_curr, target_curr)
|
||||||
name,
|
i = _convert_value(converter, r["total_income"], base_curr, target_curr)
|
||||||
f"${r['total_balance']:.2f}",
|
e = _convert_value(converter, r["total_expenses"], base_curr, target_curr)
|
||||||
f"${r['total_income']:.2f}",
|
fb = _fmt(b, target_curr, symbol)
|
||||||
f"${r['total_expenses']:.2f}",
|
fi = _fmt(i, target_curr, symbol)
|
||||||
)
|
fe = _fmt(e, target_curr, symbol)
|
||||||
|
table.add_row(name, fb, fi, fe)
|
||||||
console.print(table)
|
console.print(table)
|
||||||
|
|
||||||
|
|
||||||
@app.command()
|
@app.command()
|
||||||
def import_xlsx(
|
def import_xlsx(
|
||||||
path: str = typer.Argument(..., help="Путь к .xlsx файлу"),
|
path: str = typer.Argument(..., help=t("cmd.import.arg.path")),
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Импорт данных из Excel"""
|
"""Импорт данных из Excel"""
|
||||||
sync = ExcelSync()
|
sync = ExcelSync()
|
||||||
try:
|
try:
|
||||||
model = sync.import_model(path)
|
model = sync.import_model(path)
|
||||||
_save_model(model)
|
_save_model(model)
|
||||||
console.print(f"[green]OK[/green] Импортировано: {len(model.accounts)} счетов, "
|
console.print(t("cmd.import.ok",
|
||||||
f"{len(model.transactions)} транзакций, "
|
accounts=len(model.accounts),
|
||||||
f"{len(model.recurring)} регулярных платежей, "
|
transactions=len(model.transactions),
|
||||||
f"{len(model.assets)} активов, "
|
recurring=len(model.recurring),
|
||||||
f"{len(model.liabilities)} обязательств")
|
assets=len(model.assets),
|
||||||
|
liabilities=len(model.liabilities)))
|
||||||
|
console.print(t("cmd.import.base_currency", currency=model.base_currency))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
console.print(f"[red]Ошибка импорта: {e}[/red]")
|
console.print(f"[red]{t('cmd.import.err', error=str(e))}[/red]")
|
||||||
raise typer.Exit(1)
|
raise typer.Exit(1)
|
||||||
|
|
||||||
|
|
||||||
@app.command()
|
@app.command()
|
||||||
def export_xlsx(
|
def export_xlsx(
|
||||||
path: str = typer.Argument("exports/forecast.xlsx", help="Путь для .xlsx файла"),
|
path: str = typer.Argument("exports/forecast.xlsx", help=t("cmd.export.arg.path")),
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Экспорт модели в Excel"""
|
"""Экспорт модели в Excel"""
|
||||||
model = _load_model()
|
model = _load_model()
|
||||||
sync = ExcelSync()
|
sync = ExcelSync()
|
||||||
try:
|
try:
|
||||||
sync.export_model(model, path)
|
sync.export_model(model, path)
|
||||||
console.print(f"[green]OK[/green] Модель экспортирована в {path}")
|
console.print(t("cmd.export.ok", path=path))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
console.print(f"[red]Ошибка экспорта: {e}[/red]")
|
console.print(f"[red]{t('cmd.export.err', error=str(e))}[/red]")
|
||||||
raise typer.Exit(1)
|
raise typer.Exit(1)
|
||||||
|
|
||||||
|
|
||||||
@app.command()
|
@app.command()
|
||||||
def analyze(
|
def analyze(
|
||||||
months: int = typer.Option(12, "--months", "-m", help="Количество месяцев для анализа"),
|
months: int = typer.Option(12, "--months", "-m", help=t("cmd.analyze.opt.months")),
|
||||||
|
currency: str | None = CURRENCY_OPTION,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""AI-анализ финансовой модели"""
|
"""AI-анализ финансовой модели"""
|
||||||
model = _load_model()
|
model = _load_model()
|
||||||
assistant = AssistantService(model)
|
target_curr, _ = _resolve_currency(model, currency)
|
||||||
|
converter = _get_converter(model)
|
||||||
|
assistant = AssistantService(model, converter=converter, display_currency=target_curr)
|
||||||
result = assistant.analyze(months)
|
result = assistant.analyze(months)
|
||||||
|
|
||||||
console.print("[bold]Промпт для AI:[/bold]")
|
console.print(f"[bold]{t('cmd.analyze.title.prompt')}:[/bold]")
|
||||||
console.print(result["prompt"][:500] + "...\n")
|
console.print(result["prompt"][:500] + "...\n")
|
||||||
|
|
||||||
console.print("[bold]Сводка:[/bold]")
|
console.print(f"[bold]{t('cmd.analyze.title.summary')}:[/bold]")
|
||||||
s = result["summary"]
|
console.print(t("cmd.analyze.base_currency", currency=model.base_currency))
|
||||||
console.print(f"Баланс: ${s['total_balance']:.2f}")
|
console.print(f"\n[yellow]{t('cmd.analyze.stub')}[/yellow]")
|
||||||
console.print(f"Доход: ${s['total_income']:.2f}")
|
|
||||||
console.print(f"Расход: ${s['total_expenses']:.2f}")
|
|
||||||
console.print(
|
|
||||||
"\n[yellow]AI-ответ: заглушка. Подключите реальный API в ai/assistant.py[/yellow]"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
|
@app.command()
|
||||||
|
def info() -> None:
|
||||||
|
"""Сводка всей финансовой модели"""
|
||||||
|
model = _load_model()
|
||||||
|
converter = _get_converter(model)
|
||||||
|
symbol = converter.get_symbol(model.base_currency)
|
||||||
|
|
||||||
|
console.print(f"[bold]{t('cmd.info.metadata')}[/bold]")
|
||||||
|
console.print(f" {t('cmd.info.base_currency', currency=model.base_currency, symbol=symbol)}")
|
||||||
|
console.print(f" {t('cmd.info.accounts', count=len(model.accounts))}")
|
||||||
|
console.print(f" {t('cmd.info.transactions', count=len(model.transactions))}")
|
||||||
|
console.print(f" {t('cmd.info.recurring', count=len(model.recurring))}")
|
||||||
|
console.print(f" {t('cmd.info.assets', count=len(model.assets))}")
|
||||||
|
console.print(f" {t('cmd.info.liabilities', count=len(model.liabilities))}")
|
||||||
|
console.print(f" {t('cmd.info.rates', count=len(model.exchange_rates))}")
|
||||||
|
|
||||||
|
if model.accounts:
|
||||||
|
tbl = Table(title=t("table.accounts.title"))
|
||||||
|
tbl.add_column(t("table.accounts.col.name"), style="cyan")
|
||||||
|
tbl.add_column(t("table.accounts.col.currency"))
|
||||||
|
tbl.add_column(t("table.accounts.col.balance"), justify="right")
|
||||||
|
for a in model.accounts:
|
||||||
|
tbl.add_row(a.name, a.currency, f"{a.balance:,.2f}")
|
||||||
|
console.print(tbl)
|
||||||
|
|
||||||
|
if model.transactions:
|
||||||
|
tbl = Table(title=t("table.transactions.title"))
|
||||||
|
tbl.add_column(t("table.transactions.col.date"))
|
||||||
|
tbl.add_column(t("table.transactions.col.category"))
|
||||||
|
tbl.add_column(t("table.transactions.col.amount"), justify="right")
|
||||||
|
tbl.add_column(t("table.transactions.col.description"))
|
||||||
|
for tx in model.transactions[:10]:
|
||||||
|
tbl.add_row(tx.date, tx.category, f"{tx.amount:,.2f}", tx.description)
|
||||||
|
if len(model.transactions) > 10:
|
||||||
|
console.print(tbl)
|
||||||
|
console.print(t("cmd.info.more", count=len(model.transactions) - 10))
|
||||||
|
else:
|
||||||
|
console.print(tbl)
|
||||||
|
|
||||||
|
if model.recurring:
|
||||||
|
tbl = Table(title=t("table.recurring.title"))
|
||||||
|
tbl.add_column(t("table.recurring.col.period"))
|
||||||
|
tbl.add_column(t("table.recurring.col.amount"), justify="right")
|
||||||
|
tbl.add_column(t("table.recurring.col.category"))
|
||||||
|
for r in model.recurring:
|
||||||
|
tbl.add_row(r.frequency, f"{r.amount:,.2f}", r.category)
|
||||||
|
console.print(tbl)
|
||||||
|
|
||||||
|
if model.assets:
|
||||||
|
tbl = Table(title=t("table.assets.title"))
|
||||||
|
tbl.add_column(t("table.assets.col.name"), style="cyan")
|
||||||
|
tbl.add_column(t("table.assets.col.value"), justify="right")
|
||||||
|
tbl.add_column(t("table.assets.col.growth"), justify="right")
|
||||||
|
for a in model.assets:
|
||||||
|
tbl.add_row(a.name, f"{a.value:,.2f}", f"{a.growth_rate:.1f}%")
|
||||||
|
console.print(tbl)
|
||||||
|
|
||||||
|
if model.liabilities:
|
||||||
|
tbl = Table(title=t("table.liabilities.title"))
|
||||||
|
tbl.add_column(t("table.liabilities.col.name"), style="cyan")
|
||||||
|
tbl.add_column(t("table.liabilities.col.balance"), justify="right")
|
||||||
|
tbl.add_column(t("table.liabilities.col.rate"), justify="right")
|
||||||
|
tbl.add_column(t("table.liabilities.col.payment"), justify="right")
|
||||||
|
for li in model.liabilities:
|
||||||
|
tbl.add_row(li.name, f"{li.balance:,.2f}", f"{li.interest:.1f}%", f"{li.payment:,.2f}")
|
||||||
|
console.print(tbl)
|
||||||
|
|
||||||
|
|
||||||
|
app.add_typer(config_app)
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
app()
|
app()
|
||||||
|
|||||||
Binary file not shown.
+95
-6
@@ -1,8 +1,97 @@
|
|||||||
{
|
{
|
||||||
"accounts": [],
|
"base_currency": "RUB",
|
||||||
"transactions": [],
|
"accounts": [
|
||||||
"recurring": [],
|
{
|
||||||
"assets": [],
|
"id": "cfff5fa0-d42d-46d8-81ec-f460234e4123",
|
||||||
"liabilities": [],
|
"name": "Основной счёт",
|
||||||
"scenarios": []
|
"currency": "RUB",
|
||||||
|
"balance": 2000
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"transactions": [
|
||||||
|
],
|
||||||
|
"recurring": [
|
||||||
|
{
|
||||||
|
"id": "479a9960-d7ec-476b-9621-888ceaa1b671",
|
||||||
|
"start_date": "2026-01-01",
|
||||||
|
"end_date": "",
|
||||||
|
"frequency": "monthly",
|
||||||
|
"amount": 30000,
|
||||||
|
"category": "Зарплата"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "eb565346-679b-4560-89d1-7b0c80bbcb5a",
|
||||||
|
"start_date": "2026-01-01",
|
||||||
|
"end_date": "",
|
||||||
|
"frequency": "monthly",
|
||||||
|
"amount": -10000,
|
||||||
|
"category": "Аренда"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "30c168a3-cbe0-4d8e-a842-d430d98a80b6",
|
||||||
|
"start_date": "2026-01-01",
|
||||||
|
"end_date": "",
|
||||||
|
"frequency": "monthly",
|
||||||
|
"amount": -3000,
|
||||||
|
"category": "Коммуналка"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "f37ef9d3-76a8-4eb3-9a12-e20817d87079",
|
||||||
|
"start_date": "2026-01-01",
|
||||||
|
"end_date": "",
|
||||||
|
"frequency": "monthly",
|
||||||
|
"amount": -8000,
|
||||||
|
"category": "Продукты"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "f8ef073e-a8ca-4637-bd63-e4faeb1b282f",
|
||||||
|
"start_date": "2026-01-01",
|
||||||
|
"end_date": "",
|
||||||
|
"frequency": "monthly",
|
||||||
|
"amount": -2000,
|
||||||
|
"category": "Транспорт"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ae8f6ad5-2c66-40d9-b102-64076e9be978",
|
||||||
|
"start_date": "2026-01-01",
|
||||||
|
"end_date": "",
|
||||||
|
"frequency": "monthly",
|
||||||
|
"amount": -500,
|
||||||
|
"category": "Телефон"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "fb1197de-727e-49db-9fb8-c5b2554b0504",
|
||||||
|
"start_date": "2026-01-01",
|
||||||
|
"end_date": "",
|
||||||
|
"frequency": "monthly",
|
||||||
|
"amount": -500,
|
||||||
|
"category": "Подписки"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "d8e3392a-4015-4920-b27c-e72f99e6bbb7",
|
||||||
|
"start_date": "2026-01-01",
|
||||||
|
"end_date": "",
|
||||||
|
"frequency": "monthly",
|
||||||
|
"amount": -3000,
|
||||||
|
"category": "Досуг"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"assets": [
|
||||||
|
{
|
||||||
|
"id": "bd5080c0-45a9-40b1-9dc4-c1ec41157bd7",
|
||||||
|
"name": "Вклад в банке",
|
||||||
|
"value": 35000,
|
||||||
|
"growth_rate": 12
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"liabilities": [
|
||||||
|
],
|
||||||
|
"scenarios": [],
|
||||||
|
"exchange_rates": [
|
||||||
|
{
|
||||||
|
"from_currency": "USD",
|
||||||
|
"to_currency": "RUB",
|
||||||
|
"rate": 80
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
+24
-21
@@ -17,17 +17,33 @@ class ForecastService:
|
|||||||
|
|
||||||
results = []
|
results = []
|
||||||
for account in self.model.accounts:
|
for account in self.model.accounts:
|
||||||
balance = account.balance
|
|
||||||
monthly = self._project_account(account, months)
|
monthly = self._project_account(account, months)
|
||||||
for m in range(months):
|
for m in range(months):
|
||||||
balance = monthly[m]["balance"]
|
|
||||||
results.append({
|
results.append({
|
||||||
"account": account.name,
|
"account": account.name,
|
||||||
"month": m + 1,
|
"month": m + 1,
|
||||||
"balance": round(balance, 2),
|
"balance": monthly[m]["balance"],
|
||||||
"income": round(monthly[m]["income"], 2),
|
"income": monthly[m]["income"],
|
||||||
"expenses": round(monthly[m]["expenses"], 2),
|
"expenses": monthly[m]["expenses"],
|
||||||
})
|
})
|
||||||
|
|
||||||
|
# Asset growth — once per month, distributed across accounts proportionally
|
||||||
|
for m in range(months):
|
||||||
|
total_growth = sum(
|
||||||
|
a.value * a.growth_rate / 100 / 12
|
||||||
|
for a in self.model.assets
|
||||||
|
)
|
||||||
|
month_rows = [r for r in results if r["month"] == m + 1]
|
||||||
|
total_bal = sum(r["balance"] for r in month_rows) or 1
|
||||||
|
for r in month_rows:
|
||||||
|
share = r["balance"] / total_bal
|
||||||
|
r["income"] = round(r["income"] + total_growth * share, 2)
|
||||||
|
r["balance"] = round(r["balance"] + total_growth * share, 2)
|
||||||
|
|
||||||
|
# Compound asset values for next month
|
||||||
|
for a in self.model.assets:
|
||||||
|
a.value += a.value * a.growth_rate / 100 / 12
|
||||||
|
|
||||||
return results
|
return results
|
||||||
|
|
||||||
def _project_account(self, account: Account, months: int) -> list[dict]:
|
def _project_account(self, account: Account, months: int) -> list[dict]:
|
||||||
@@ -50,30 +66,17 @@ class ForecastService:
|
|||||||
else:
|
else:
|
||||||
expenses += abs(r.amount)
|
expenses += abs(r.amount)
|
||||||
|
|
||||||
income += self._asset_income(account)
|
|
||||||
expenses += self._liability_cost(account)
|
expenses += self._liability_cost(account)
|
||||||
|
|
||||||
balance += income - expenses
|
balance += income - expenses
|
||||||
|
|
||||||
asset_growth = sum(
|
|
||||||
a.value * a.growth_rate / 12
|
|
||||||
for a in self.model.assets
|
|
||||||
)
|
|
||||||
balance += asset_growth
|
|
||||||
|
|
||||||
monthly.append({
|
monthly.append({
|
||||||
"balance": balance,
|
"balance": round(balance, 2),
|
||||||
"income": income,
|
"income": round(income, 2),
|
||||||
"expenses": expenses,
|
"expenses": round(expenses, 2),
|
||||||
})
|
})
|
||||||
return monthly
|
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:
|
def _liability_cost(self, account: Account) -> float:
|
||||||
total = 0.0
|
total = 0.0
|
||||||
for liability in self.model.liabilities:
|
for liability in self.model.liabilities:
|
||||||
|
|||||||
+1
-1
@@ -21,7 +21,7 @@ include = ["cashflow_model*", "sync*", "engine*", "ai*", "cli*"]
|
|||||||
|
|
||||||
[tool.ruff]
|
[tool.ruff]
|
||||||
target-version = "py311"
|
target-version = "py311"
|
||||||
line-length = 100
|
line-length = 120
|
||||||
|
|
||||||
[tool.ruff.lint]
|
[tool.ruff.lint]
|
||||||
select = ["E", "F", "I", "N", "W"]
|
select = ["E", "F", "I", "N", "W"]
|
||||||
|
|||||||
+28
-1
@@ -3,7 +3,15 @@ from uuid import UUID
|
|||||||
|
|
||||||
from openpyxl import Workbook, load_workbook
|
from openpyxl import Workbook, load_workbook
|
||||||
|
|
||||||
from cashflow_model import Account, Asset, FinancialModel, Liability, RecurringCashflow, Transaction
|
from cashflow_model import (
|
||||||
|
Account,
|
||||||
|
Asset,
|
||||||
|
ExchangeRate,
|
||||||
|
FinancialModel,
|
||||||
|
Liability,
|
||||||
|
RecurringCashflow,
|
||||||
|
Transaction,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class SyncError(Exception):
|
class SyncError(Exception):
|
||||||
@@ -31,6 +39,10 @@ _SHEET_CONFIG = {
|
|||||||
"fields": ["id", "name", "balance", "interest", "payment"],
|
"fields": ["id", "name", "balance", "interest", "payment"],
|
||||||
"cls": Liability,
|
"cls": Liability,
|
||||||
},
|
},
|
||||||
|
"ExchangeRates": {
|
||||||
|
"fields": ["from_currency", "to_currency", "rate"],
|
||||||
|
"cls": ExchangeRate,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -42,6 +54,7 @@ class ExcelSync:
|
|||||||
|
|
||||||
wb = load_workbook(path, read_only=True, data_only=True)
|
wb = load_workbook(path, read_only=True, data_only=True)
|
||||||
model = FinancialModel()
|
model = FinancialModel()
|
||||||
|
model.exchange_rates = []
|
||||||
|
|
||||||
for sheet_name, config in _SHEET_CONFIG.items():
|
for sheet_name, config in _SHEET_CONFIG.items():
|
||||||
if sheet_name not in wb.sheetnames:
|
if sheet_name not in wb.sheetnames:
|
||||||
@@ -62,6 +75,13 @@ class ExcelSync:
|
|||||||
data[header] = str(val) if not isinstance(val, (int, float)) else val
|
data[header] = str(val) if not isinstance(val, (int, float)) else val
|
||||||
self._add_to_model(model, sheet_name, data)
|
self._add_to_model(model, sheet_name, data)
|
||||||
|
|
||||||
|
if "ModelInfo" in wb.sheetnames:
|
||||||
|
ws = wb["ModelInfo"]
|
||||||
|
rows = list(ws.iter_rows(values_only=True))
|
||||||
|
for row in rows:
|
||||||
|
if row[0] and str(row[0]).strip().lower() == "base_currency" and len(row) > 1:
|
||||||
|
model.base_currency = str(row[1]).strip()
|
||||||
|
|
||||||
wb.close()
|
wb.close()
|
||||||
return model
|
return model
|
||||||
|
|
||||||
@@ -76,6 +96,7 @@ class ExcelSync:
|
|||||||
"Recurring": model.recurring,
|
"Recurring": model.recurring,
|
||||||
"Assets": model.assets,
|
"Assets": model.assets,
|
||||||
"Liabilities": model.liabilities,
|
"Liabilities": model.liabilities,
|
||||||
|
"ExchangeRates": model.exchange_rates,
|
||||||
}
|
}
|
||||||
|
|
||||||
for sheet_name, items in collections.items():
|
for sheet_name, items in collections.items():
|
||||||
@@ -90,6 +111,10 @@ class ExcelSync:
|
|||||||
]
|
]
|
||||||
ws.append(row)
|
ws.append(row)
|
||||||
|
|
||||||
|
ws_info = wb.create_sheet(title="ModelInfo")
|
||||||
|
ws_info.append(["Property", "Value"])
|
||||||
|
ws_info.append(["base_currency", model.base_currency])
|
||||||
|
|
||||||
wb.save(path)
|
wb.save(path)
|
||||||
|
|
||||||
def _add_to_model(self, model: FinancialModel, sheet_name: str, data: dict) -> None:
|
def _add_to_model(self, model: FinancialModel, sheet_name: str, data: dict) -> None:
|
||||||
@@ -104,5 +129,7 @@ class ExcelSync:
|
|||||||
model.assets.append(Asset.from_dict(data))
|
model.assets.append(Asset.from_dict(data))
|
||||||
elif sheet_name == "Liabilities":
|
elif sheet_name == "Liabilities":
|
||||||
model.liabilities.append(Liability.from_dict(data))
|
model.liabilities.append(Liability.from_dict(data))
|
||||||
|
elif sheet_name == "ExchangeRates":
|
||||||
|
model.exchange_rates.append(ExchangeRate.from_dict(data))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise SyncError(f"Failed to parse row in {sheet_name}: {e}") from e
|
raise SyncError(f"Failed to parse row in {sheet_name}: {e}") from e
|
||||||
|
|||||||
+13
-2
@@ -5,6 +5,8 @@ import pytest
|
|||||||
from cashflow_model import (
|
from cashflow_model import (
|
||||||
Account,
|
Account,
|
||||||
Asset,
|
Asset,
|
||||||
|
CurrencyConverter,
|
||||||
|
ExchangeRate,
|
||||||
FinancialModel,
|
FinancialModel,
|
||||||
Liability,
|
Liability,
|
||||||
RecurringCashflow,
|
RecurringCashflow,
|
||||||
@@ -16,9 +18,10 @@ from cashflow_model import (
|
|||||||
def sample_model() -> FinancialModel:
|
def sample_model() -> FinancialModel:
|
||||||
acc_id = uuid4()
|
acc_id = uuid4()
|
||||||
return FinancialModel(
|
return FinancialModel(
|
||||||
|
base_currency="RUB",
|
||||||
accounts=[
|
accounts=[
|
||||||
Account(id=acc_id, name="Основной счёт", currency="USD", balance=5000.0),
|
Account(id=acc_id, name="Основной счёт", currency="RUB", balance=5000.0),
|
||||||
Account(name="Сбережения", currency="USD", balance=10000.0),
|
Account(name="Сбережения", currency="RUB", balance=10000.0),
|
||||||
],
|
],
|
||||||
transactions=[
|
transactions=[
|
||||||
Transaction(
|
Transaction(
|
||||||
@@ -48,9 +51,17 @@ def sample_model() -> FinancialModel:
|
|||||||
liabilities=[
|
liabilities=[
|
||||||
Liability(name="Кредит", balance=20000.0, interest=5.0, payment=500.0),
|
Liability(name="Кредит", balance=20000.0, interest=5.0, payment=500.0),
|
||||||
],
|
],
|
||||||
|
exchange_rates=[
|
||||||
|
ExchangeRate(from_currency="USD", to_currency="RUB", rate=80.0),
|
||||||
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def empty_model() -> FinancialModel:
|
def empty_model() -> FinancialModel:
|
||||||
return FinancialModel()
|
return FinancialModel()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def sample_converter() -> CurrencyConverter:
|
||||||
|
return CurrencyConverter([ExchangeRate(from_currency="USD", to_currency="RUB", rate=80.0)])
|
||||||
|
|||||||
+144
-1
@@ -1,21 +1,35 @@
|
|||||||
from typer.testing import CliRunner
|
from typer.testing import CliRunner
|
||||||
|
|
||||||
from cli.main import app
|
from cli.main import MODEL_PATH, app
|
||||||
|
|
||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
|
|
||||||
|
|
||||||
|
def _cleanup():
|
||||||
|
if MODEL_PATH.exists():
|
||||||
|
MODEL_PATH.unlink()
|
||||||
|
|
||||||
|
|
||||||
class TestCli:
|
class TestCli:
|
||||||
def test_init(self):
|
def test_init(self):
|
||||||
|
_cleanup()
|
||||||
result = runner.invoke(app, ["init"])
|
result = runner.invoke(app, ["init"])
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
assert "Пустая модель" in result.stdout
|
assert "Пустая модель" in result.stdout
|
||||||
|
|
||||||
def test_forecast_after_init(self):
|
def test_forecast_after_init(self):
|
||||||
|
_cleanup()
|
||||||
runner.invoke(app, ["init"])
|
runner.invoke(app, ["init"])
|
||||||
result = runner.invoke(app, ["forecast", "--months", "3"])
|
result = runner.invoke(app, ["forecast", "--months", "3"])
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
|
|
||||||
|
def test_forecast_with_currency(self):
|
||||||
|
_cleanup()
|
||||||
|
runner.invoke(app, ["init"])
|
||||||
|
result = runner.invoke(app, ["forecast", "--months", "3", "--currency", "USD"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "$" in result.stdout
|
||||||
|
|
||||||
def test_unknown_scenario(self):
|
def test_unknown_scenario(self):
|
||||||
result = runner.invoke(app, ["scenario", "unknown"])
|
result = runner.invoke(app, ["scenario", "unknown"])
|
||||||
assert result.exit_code != 0
|
assert result.exit_code != 0
|
||||||
@@ -23,3 +37,132 @@ class TestCli:
|
|||||||
def test_help(self):
|
def test_help(self):
|
||||||
result = runner.invoke(app, ["--help"])
|
result = runner.invoke(app, ["--help"])
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
|
|
||||||
|
def test_info_empty(self):
|
||||||
|
_cleanup()
|
||||||
|
runner.invoke(app, ["init"])
|
||||||
|
result = runner.invoke(app, ["info"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "Базовая валюта" in result.stdout
|
||||||
|
|
||||||
|
def test_config_base_currency_set(self):
|
||||||
|
_cleanup()
|
||||||
|
runner.invoke(app, ["init"])
|
||||||
|
result = runner.invoke(app, ["config", "base-currency", "EUR"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "EUR" in result.stdout
|
||||||
|
|
||||||
|
info = runner.invoke(app, ["info"])
|
||||||
|
assert "EUR" in info.stdout
|
||||||
|
|
||||||
|
def test_config_base_currency_show(self):
|
||||||
|
_cleanup()
|
||||||
|
runner.invoke(app, ["init"])
|
||||||
|
result = runner.invoke(app, ["config", "base-currency"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "RUB" in result.stdout
|
||||||
|
|
||||||
|
def test_config_account_crud(self):
|
||||||
|
_cleanup()
|
||||||
|
runner.invoke(app, ["init"])
|
||||||
|
|
||||||
|
args = ["config", "account-add", "--name", "Тестовый", "--balance", "1000"]
|
||||||
|
add = runner.invoke(app, args)
|
||||||
|
assert add.exit_code == 0
|
||||||
|
assert "Тестовый" in add.stdout
|
||||||
|
|
||||||
|
lst = runner.invoke(app, ["config", "account-list"])
|
||||||
|
assert "Тестовый" in lst.stdout
|
||||||
|
|
||||||
|
remove = runner.invoke(app, ["config", "account-remove", "Тестовый"])
|
||||||
|
assert remove.exit_code == 0
|
||||||
|
|
||||||
|
lst2 = runner.invoke(app, ["config", "account-list"])
|
||||||
|
assert "Тестовый" not in lst2.stdout
|
||||||
|
|
||||||
|
def test_config_transaction_crud(self):
|
||||||
|
_cleanup()
|
||||||
|
runner.invoke(app, ["init"])
|
||||||
|
runner.invoke(app, ["config", "account-add", "--name", "Счёт", "--balance", "0"])
|
||||||
|
|
||||||
|
add = runner.invoke(app, [
|
||||||
|
"config", "transaction-add",
|
||||||
|
"--account", "Счёт",
|
||||||
|
"--amount", "5000",
|
||||||
|
"--category", "income",
|
||||||
|
"--description", "Зарплата",
|
||||||
|
])
|
||||||
|
assert add.exit_code == 0
|
||||||
|
assert "доход" in add.stdout
|
||||||
|
|
||||||
|
lst = runner.invoke(app, ["config", "transaction-list"])
|
||||||
|
assert "Зарплата" in lst.stdout
|
||||||
|
|
||||||
|
def test_config_asset_crud(self):
|
||||||
|
_cleanup()
|
||||||
|
runner.invoke(app, ["init"])
|
||||||
|
|
||||||
|
add = runner.invoke(app, [
|
||||||
|
"config", "asset-add",
|
||||||
|
"--name", "Квартира",
|
||||||
|
"--value", "5000000",
|
||||||
|
"--growth", "5",
|
||||||
|
])
|
||||||
|
assert add.exit_code == 0
|
||||||
|
assert "Квартира" in add.stdout
|
||||||
|
|
||||||
|
lst = runner.invoke(app, ["config", "asset-list"])
|
||||||
|
assert "Квартира" in lst.stdout
|
||||||
|
|
||||||
|
def test_config_liability_crud(self):
|
||||||
|
_cleanup()
|
||||||
|
runner.invoke(app, ["init"])
|
||||||
|
|
||||||
|
add = runner.invoke(app, [
|
||||||
|
"config", "liability-add",
|
||||||
|
"--name", "Кредит",
|
||||||
|
"--balance", "100000",
|
||||||
|
"--interest", "10",
|
||||||
|
"--payment", "5000",
|
||||||
|
])
|
||||||
|
assert add.exit_code == 0
|
||||||
|
assert "Кредит" in add.stdout
|
||||||
|
|
||||||
|
def test_config_recurring_crud(self):
|
||||||
|
_cleanup()
|
||||||
|
runner.invoke(app, ["init"])
|
||||||
|
|
||||||
|
add = runner.invoke(app, [
|
||||||
|
"config", "recurring-add",
|
||||||
|
"--amount", "-500",
|
||||||
|
"--category", "аренда",
|
||||||
|
"--frequency", "monthly",
|
||||||
|
])
|
||||||
|
assert add.exit_code == 0
|
||||||
|
|
||||||
|
lst = runner.invoke(app, ["config", "recurring-list"])
|
||||||
|
assert "аренда" in lst.stdout
|
||||||
|
|
||||||
|
def test_config_rate_crud(self):
|
||||||
|
_cleanup()
|
||||||
|
runner.invoke(app, ["init"])
|
||||||
|
|
||||||
|
set_r = runner.invoke(app, ["config", "rate-set", "EUR", "RUB", "90"])
|
||||||
|
assert set_r.exit_code == 0
|
||||||
|
|
||||||
|
lst = runner.invoke(app, ["config", "rate-list"])
|
||||||
|
assert "EUR" in lst.stdout
|
||||||
|
assert "90" in lst.stdout
|
||||||
|
|
||||||
|
def test_info_with_data(self):
|
||||||
|
_cleanup()
|
||||||
|
runner.invoke(app, ["init"])
|
||||||
|
acc_args = ["config", "account-add", "--name", "Основной", "--balance", "50000"]
|
||||||
|
runner.invoke(app, acc_args)
|
||||||
|
asset_args = ["config", "asset-add", "--name", "Акции",
|
||||||
|
"--value", "100000", "--growth", "8.0"]
|
||||||
|
runner.invoke(app, asset_args)
|
||||||
|
|
||||||
|
result = runner.invoke(app, ["info"])
|
||||||
|
assert "Основной" in result.stdout
|
||||||
|
assert "Акции" in result.stdout
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import pytest
|
||||||
|
|
||||||
|
from cashflow_model import CurrencyConverter, CurrencyError, ExchangeRate
|
||||||
|
|
||||||
|
|
||||||
|
class TestExchangeRate:
|
||||||
|
def test_to_dict_roundtrip(self):
|
||||||
|
rate = ExchangeRate(from_currency="USD", to_currency="RUB", rate=80.0)
|
||||||
|
d = rate.to_dict()
|
||||||
|
r2 = ExchangeRate.from_dict(d)
|
||||||
|
assert r2.from_currency == "USD"
|
||||||
|
assert r2.to_currency == "RUB"
|
||||||
|
assert r2.rate == 80.0
|
||||||
|
|
||||||
|
def test_defaults(self):
|
||||||
|
r = ExchangeRate()
|
||||||
|
assert r.from_currency == "USD"
|
||||||
|
assert r.to_currency == "RUB"
|
||||||
|
assert r.rate == 80.0
|
||||||
|
|
||||||
|
|
||||||
|
class TestCurrencyConverter:
|
||||||
|
def test_convert_usd_to_rub(self, sample_converter):
|
||||||
|
result = sample_converter.convert(100, "USD", "RUB")
|
||||||
|
assert result == 8000.0
|
||||||
|
|
||||||
|
def test_convert_rub_to_usd(self, sample_converter):
|
||||||
|
result = sample_converter.convert(8000, "RUB", "USD")
|
||||||
|
assert result == 100.0
|
||||||
|
|
||||||
|
def test_same_currency(self, sample_converter):
|
||||||
|
result = sample_converter.convert(500, "USD", "USD")
|
||||||
|
assert result == 500.0
|
||||||
|
|
||||||
|
def test_unknown_pair(self):
|
||||||
|
converter = CurrencyConverter()
|
||||||
|
with pytest.raises(CurrencyError):
|
||||||
|
converter.convert(100, "USD", "RUB")
|
||||||
|
|
||||||
|
def test_negative_rate(self):
|
||||||
|
converter = CurrencyConverter()
|
||||||
|
with pytest.raises(CurrencyError):
|
||||||
|
converter.set_rate("USD", "RUB", -1)
|
||||||
|
|
||||||
|
def test_zero_rate(self):
|
||||||
|
converter = CurrencyConverter()
|
||||||
|
with pytest.raises(CurrencyError):
|
||||||
|
converter.set_rate("USD", "RUB", 0)
|
||||||
|
|
||||||
|
def test_inverse_auto(self):
|
||||||
|
rate = ExchangeRate(from_currency="EUR", to_currency="RUB", rate=90.0)
|
||||||
|
converter = CurrencyConverter([rate])
|
||||||
|
assert converter.convert(900, "RUB", "EUR") == 10.0
|
||||||
|
|
||||||
|
def test_get_symbol(self, sample_converter):
|
||||||
|
assert sample_converter.get_symbol("USD") == "$"
|
||||||
|
assert sample_converter.get_symbol("RUB") == "₽"
|
||||||
|
assert sample_converter.get_symbol("XYZ") == "XYZ"
|
||||||
|
|
||||||
|
def test_with_defaults(self):
|
||||||
|
converter = CurrencyConverter.with_defaults()
|
||||||
|
assert converter.convert(10, "USD", "RUB") == 800.0
|
||||||
|
|
||||||
|
def test_multiple_rates(self):
|
||||||
|
rates = [
|
||||||
|
ExchangeRate(from_currency="USD", to_currency="RUB", rate=80.0),
|
||||||
|
ExchangeRate(from_currency="EUR", to_currency="RUB", rate=90.0),
|
||||||
|
]
|
||||||
|
converter = CurrencyConverter(rates)
|
||||||
|
assert converter.convert(10, "USD", "RUB") == 800.0
|
||||||
|
assert converter.convert(10, "EUR", "RUB") == 900.0
|
||||||
|
assert converter.convert(900, "RUB", "EUR") == 10.0
|
||||||
@@ -22,6 +22,16 @@ class TestExcelSync:
|
|||||||
assert len(loaded.recurring) == len(sample_model.recurring)
|
assert len(loaded.recurring) == len(sample_model.recurring)
|
||||||
assert len(loaded.assets) == len(sample_model.assets)
|
assert len(loaded.assets) == len(sample_model.assets)
|
||||||
assert len(loaded.liabilities) == len(sample_model.liabilities)
|
assert len(loaded.liabilities) == len(sample_model.liabilities)
|
||||||
|
assert len(loaded.exchange_rates) == len(sample_model.exchange_rates)
|
||||||
|
|
||||||
|
def test_roundtrip_preserves_base_currency(self, tmp_path: Path):
|
||||||
|
sync = ExcelSync()
|
||||||
|
model = FinancialModel(base_currency="EUR")
|
||||||
|
p = tmp_path / "eur_model.xlsx"
|
||||||
|
sync.export_model(model, p)
|
||||||
|
|
||||||
|
loaded = sync.import_model(p)
|
||||||
|
assert loaded.base_currency == "EUR"
|
||||||
|
|
||||||
def test_import_missing_file(self):
|
def test_import_missing_file(self):
|
||||||
sync = ExcelSync()
|
sync = ExcelSync()
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
from cli.i18n import Translator, get_lang, set_lang, setup_i18n, t
|
||||||
|
|
||||||
|
|
||||||
|
class TestTranslator:
|
||||||
|
def test_ru_by_default(self):
|
||||||
|
setup_i18n()
|
||||||
|
assert get_lang() == "ru"
|
||||||
|
|
||||||
|
def test_ru_returns_russian(self):
|
||||||
|
tr = Translator("ru")
|
||||||
|
assert "финансовую" in tr.t("cmd.init.help")
|
||||||
|
|
||||||
|
def test_en_fallback_to_ru(self):
|
||||||
|
tr = Translator("en")
|
||||||
|
result = tr.t("cmd.init.help")
|
||||||
|
assert "финансовую" in result
|
||||||
|
|
||||||
|
def test_unknown_key_returns_key(self):
|
||||||
|
tr = Translator("ru")
|
||||||
|
assert tr.t("nonexistent.key") == "nonexistent.key"
|
||||||
|
|
||||||
|
def test_set_lang(self):
|
||||||
|
setup_i18n()
|
||||||
|
set_lang("en")
|
||||||
|
assert get_lang() == "en"
|
||||||
|
|
||||||
|
def test_t_function(self):
|
||||||
|
setup_i18n()
|
||||||
|
result = t("cmd.init.ok", path="data/model.json")
|
||||||
|
assert "data/model.json" in result
|
||||||
|
assert "Пустая" in result
|
||||||
|
|
||||||
|
def test_global_ok(self):
|
||||||
|
result = t("global.ok")
|
||||||
|
assert result == "OK"
|
||||||
|
|
||||||
|
def test_prompt_analyze(self):
|
||||||
|
result = t("prompt.analyze")
|
||||||
|
assert "AI-ассистент" in result
|
||||||
|
|
||||||
|
def test_custom_lang_init(self):
|
||||||
|
tr = Translator("de")
|
||||||
|
result = tr.t("cmd.init.help")
|
||||||
|
assert "финансовую" in result # fallback to ru
|
||||||
@@ -3,6 +3,7 @@ from pathlib import Path
|
|||||||
from cashflow_model import (
|
from cashflow_model import (
|
||||||
Account,
|
Account,
|
||||||
Asset,
|
Asset,
|
||||||
|
ExchangeRate,
|
||||||
FinancialModel,
|
FinancialModel,
|
||||||
ForecastScenario,
|
ForecastScenario,
|
||||||
Liability,
|
Liability,
|
||||||
@@ -42,6 +43,32 @@ class TestTransaction:
|
|||||||
|
|
||||||
|
|
||||||
class TestFinancialModel:
|
class TestFinancialModel:
|
||||||
|
def test_default_base_currency(self):
|
||||||
|
model = FinancialModel()
|
||||||
|
assert model.base_currency == "RUB"
|
||||||
|
|
||||||
|
def test_base_currency_roundtrip(self, tmp_path: Path):
|
||||||
|
model = FinancialModel(base_currency="EUR")
|
||||||
|
p = tmp_path / "model.json"
|
||||||
|
model.save(p)
|
||||||
|
loaded = FinancialModel.load(p)
|
||||||
|
assert loaded.base_currency == "EUR"
|
||||||
|
|
||||||
|
def test_base_currency_backward_compat(self, tmp_path: Path):
|
||||||
|
import json
|
||||||
|
p = tmp_path / "legacy.json"
|
||||||
|
with open(p, "w") as f:
|
||||||
|
json.dump({"accounts": []}, f)
|
||||||
|
loaded = FinancialModel.load(p)
|
||||||
|
assert loaded.base_currency == "RUB"
|
||||||
|
|
||||||
|
def test_exchange_rates_default(self):
|
||||||
|
model = FinancialModel()
|
||||||
|
assert len(model.exchange_rates) == 1
|
||||||
|
assert model.exchange_rates[0].from_currency == "USD"
|
||||||
|
assert model.exchange_rates[0].to_currency == "RUB"
|
||||||
|
assert model.exchange_rates[0].rate == 80.0
|
||||||
|
|
||||||
def test_save_load(self, tmp_path: Path):
|
def test_save_load(self, tmp_path: Path):
|
||||||
model = FinancialModel()
|
model = FinancialModel()
|
||||||
model.accounts.append(Account(name="Test", balance=100.0))
|
model.accounts.append(Account(name="Test", balance=100.0))
|
||||||
@@ -61,6 +88,8 @@ class TestFinancialModel:
|
|||||||
d = model.to_dict()
|
d = model.to_dict()
|
||||||
assert d["accounts"] == []
|
assert d["accounts"] == []
|
||||||
assert d["transactions"] == []
|
assert d["transactions"] == []
|
||||||
|
assert d["base_currency"] == "RUB"
|
||||||
|
assert "exchange_rates" in d
|
||||||
|
|
||||||
def test_all_entities_roundtrip(self, tmp_path: Path):
|
def test_all_entities_roundtrip(self, tmp_path: Path):
|
||||||
model = FinancialModel(
|
model = FinancialModel(
|
||||||
@@ -70,6 +99,7 @@ class TestFinancialModel:
|
|||||||
assets=[Asset(name="Stock", value=1000.0)],
|
assets=[Asset(name="Stock", value=1000.0)],
|
||||||
liabilities=[Liability(name="Loan", balance=500.0, interest=5.0, payment=100.0)],
|
liabilities=[Liability(name="Loan", balance=500.0, interest=5.0, payment=100.0)],
|
||||||
scenarios=[ForecastScenario(name="test")],
|
scenarios=[ForecastScenario(name="test")],
|
||||||
|
exchange_rates=[ExchangeRate(from_currency="USD", to_currency="RUB", rate=80.0)],
|
||||||
)
|
)
|
||||||
p = tmp_path / "full.json"
|
p = tmp_path / "full.json"
|
||||||
model.save(p)
|
model.save(p)
|
||||||
@@ -80,3 +110,4 @@ class TestFinancialModel:
|
|||||||
assert len(loaded.assets) == 1
|
assert len(loaded.assets) == 1
|
||||||
assert len(loaded.liabilities) == 1
|
assert len(loaded.liabilities) == 1
|
||||||
assert len(loaded.scenarios) == 1
|
assert len(loaded.scenarios) == 1
|
||||||
|
assert len(loaded.exchange_rates) == 1
|
||||||
|
|||||||
Reference in New Issue
Block a user