T1: restructure to domain/application/infrastructure + schema version 1

- Move cashflow_model/ -> domain/
- Move engine/ -> application/
- Move cli/, sync/, ai/ -> infrastructure/
- Update all imports across codebase and tests
- Add version=1 to FinancialModel.to_dict() with migration hook in from_dict
- Update pyproject.toml: cf entry point and packages.find

Tests: 63/63 pass.
This commit is contained in:
2026-10-08 16:34:10 +03:00
parent 12611edd6f
commit feb77ef217
32 changed files with 1945 additions and 109 deletions
View File
+428
View File
@@ -0,0 +1,428 @@
from pathlib import Path
import typer
from rich.console import Console
from rich.table import Table
from domain import (
Account,
Asset,
ExchangeRate,
FinancialModel,
Liability,
RecurringCashflow,
Transaction,
)
from infrastructure.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
View File
@@ -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)
+330
View File
@@ -0,0 +1,330 @@
import sys
from pathlib import Path
import typer
from rich.console import Console
from rich.table import Table
from infrastructure.ai.assistant import AssistantService
from domain import CurrencyConverter, FinancialModel
from infrastructure.cli.config import app as config_app
from infrastructure.cli.i18n import setup_i18n, t
from application.forecast import ForecastService
from application.scenarios import DEFAULT_SCENARIOS, ScenarioService
from infrastructure.sync.excel_sync import ExcelSync
try:
sys.stdout.reconfigure(encoding="utf-8")
except (AttributeError, OSError):
pass
setup_i18n()
app = typer.Typer(name="cf", help=t("app.help"))
console = Console()
DATA_DIR = Path("data")
MODEL_PATH = DATA_DIR / "model.json"
CURRENCY_OPTION = typer.Option(None, "--currency", "-c", help=t("cmd.forecast.opt.currency"))
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)
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()
def init() -> None:
"""Создать пустую финансовую модель"""
model = FinancialModel()
_save_model(model)
console.print(t("cmd.init.ok", path=str(MODEL_PATH)))
@app.command()
def forecast(
months: int = typer.Option(12, "--months", "-m", help=t("cmd.forecast.opt.months")),
currency: str | None = CURRENCY_OPTION,
) -> None:
"""Запустить прогноз денежных потоков"""
model = _load_model()
target_curr, symbol = _resolve_currency(model, currency)
converter = _get_converter(model)
base_curr = model.base_currency
service = ForecastService(model)
results = service.forecast_cashflow(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:
table = Table(title=t("cmd.forecast.title", months=months, currency=target_curr))
table.add_column(t("cmd.forecast.col.account"), style="cyan")
table.add_column(t("cmd.forecast.col.month"), style="white")
table.add_column(t("cmd.forecast.col.balance"), justify="right", style="green")
table.add_column(t("cmd.forecast.col.income"), justify="right")
table.add_column(t("cmd.forecast.col.expenses"), justify="right")
for r in results:
table.add_row(
r["account"], str(r["month"]),
_f(_cv(r["balance"])),
_f(_cv(r["income"])),
_f(_cv(r["expenses"])),
)
console.print(table)
cb = _cv(summary["total_balance"])
ci = _cv(summary["total_income"])
ce = _cv(summary["total_expenses"])
console.print(t("cmd.forecast.total", balance=_f(cb), income=_f(ci), expenses=_f(ce)))
@app.command()
def scenario(
name: str = typer.Argument("baseline", help=t("cmd.scenario.arg.name")),
months: int = typer.Option(12, "--months", "-m", help=t("cmd.scenario.opt.months")),
currency: str | None = CURRENCY_OPTION,
) -> None:
"""Применить сценарий и показать прогноз"""
model = _load_model()
target_curr, symbol = _resolve_currency(model, currency)
converter = _get_converter(model)
base_curr = model.base_currency
service = ScenarioService(model)
if name in DEFAULT_SCENARIOS:
scenario_obj = DEFAULT_SCENARIOS[name]
else:
console.print(f"[red]{t('cmd.scenario.err.unknown', name=name)}[/red]")
console.print(t("cmd.scenario.err.available", scenarios=", ".join(DEFAULT_SCENARIOS.keys())))
raise typer.Exit(1)
result = service.apply(scenario_obj, months)
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"{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()
def whatif(
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=t("cmd.whatif.opt.expense")),
growth_mult: float = typer.Option(1.0, "--growth", "-g", help=t("cmd.whatif.opt.growth")),
months: int = typer.Option(12, "--months", "-m", help=t("cmd.whatif.opt.months")),
currency: str | None = CURRENCY_OPTION,
) -> None:
"""What-if анализ с произвольными множителями"""
model = _load_model()
target_curr, symbol = _resolve_currency(model, currency)
converter = _get_converter(model)
base_curr = model.base_currency
service = ScenarioService(model)
result = service.what_if(income_mult, expense_mult, growth_mult, months)
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.whatif.title')}[/bold]")
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()
def compare(
months: int = typer.Option(12, "--months", "-m", help=t("cmd.compare.opt.months")),
currency: str | None = CURRENCY_OPTION,
) -> None:
"""Сравнить все сценарии"""
model = _load_model()
target_curr, symbol = _resolve_currency(model, currency)
converter = _get_converter(model)
base_curr = model.base_currency
service = ScenarioService(model)
results = service.compare(months)
table = Table(title=t("cmd.compare.title", currency=target_curr))
table.add_column(t("cmd.compare.col.scenario"), style="cyan")
table.add_column(t("cmd.forecast.col.balance"), justify="right")
table.add_column(t("cmd.forecast.col.income"), justify="right")
table.add_column(t("cmd.forecast.col.expenses"), justify="right")
for name, r in results.items():
b = _convert_value(converter, r["total_balance"], base_curr, target_curr)
i = _convert_value(converter, r["total_income"], base_curr, target_curr)
e = _convert_value(converter, r["total_expenses"], base_curr, target_curr)
fb = _fmt(b, target_curr, symbol)
fi = _fmt(i, target_curr, symbol)
fe = _fmt(e, target_curr, symbol)
table.add_row(name, fb, fi, fe)
console.print(table)
@app.command()
def import_xlsx(
path: str = typer.Argument(..., help=t("cmd.import.arg.path")),
) -> None:
"""Импорт данных из Excel"""
sync = ExcelSync()
try:
model = sync.import_model(path)
_save_model(model)
console.print(t("cmd.import.ok",
accounts=len(model.accounts),
transactions=len(model.transactions),
recurring=len(model.recurring),
assets=len(model.assets),
liabilities=len(model.liabilities)))
console.print(t("cmd.import.base_currency", currency=model.base_currency))
except Exception as e:
console.print(f"[red]{t('cmd.import.err', error=str(e))}[/red]")
raise typer.Exit(1)
@app.command()
def export_xlsx(
path: str = typer.Argument("exports/forecast.xlsx", help=t("cmd.export.arg.path")),
) -> None:
"""Экспорт модели в Excel"""
model = _load_model()
sync = ExcelSync()
try:
sync.export_model(model, path)
console.print(t("cmd.export.ok", path=path))
except Exception as e:
console.print(f"[red]{t('cmd.export.err', error=str(e))}[/red]")
raise typer.Exit(1)
@app.command()
def analyze(
months: int = typer.Option(12, "--months", "-m", help=t("cmd.analyze.opt.months")),
currency: str | None = CURRENCY_OPTION,
) -> None:
"""AI-анализ финансовой модели"""
model = _load_model()
target_curr, _ = _resolve_currency(model, currency)
converter = _get_converter(model)
assistant = AssistantService(model, converter=converter, display_currency=target_curr)
result = assistant.analyze(months)
console.print(f"[bold]{t('cmd.analyze.title.prompt')}:[/bold]")
console.print(result["prompt"][:500] + "...\n")
console.print(f"[bold]{t('cmd.analyze.title.summary')}:[/bold]")
console.print(t("cmd.analyze.base_currency", currency=model.base_currency))
console.print(f"\n[yellow]{t('cmd.analyze.stub')}[/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__":
app()