From 9ed71cbc58a66f8ea5bd10af0e2c07443108783e Mon Sep 17 00:00:00 2001 From: oqyude Date: Wed, 22 Jul 2026 12:12:09 +0300 Subject: [PATCH] testing new features --- ai/assistant.py | 17 +- ai/prompts.py | 22 +- cashflow_model/__init__.py | 5 + cashflow_model/currency.py | 79 +++++++ cashflow_model/model.py | 7 + cli/config.py | 419 +++++++++++++++++++++++++++++++++++++ cli/main.py | 182 +++++++++++++--- data/model.json | 208 +++++++++++++++++- data/model.xlsx | Bin 0 -> 7872 bytes sync/excel_sync.py | 29 ++- tests/conftest.py | 15 +- tests/test_cli.py | 145 ++++++++++++- tests/test_currency.py | 72 +++++++ tests/test_excel_sync.py | 10 + tests/test_model.py | 31 +++ 15 files changed, 1201 insertions(+), 40 deletions(-) create mode 100644 cashflow_model/currency.py create mode 100644 cli/config.py create mode 100644 data/model.xlsx create mode 100644 tests/test_currency.py diff --git a/ai/assistant.py b/ai/assistant.py index f21ee74..8321927 100644 --- a/ai/assistant.py +++ b/ai/assistant.py @@ -1,7 +1,7 @@ import json from ai import prompts -from cashflow_model import FinancialModel +from cashflow_model import CurrencyConverter, FinancialModel from engine.forecast import ForecastService @@ -10,8 +10,15 @@ class AssistantError(Exception): 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.converter = converter or CurrencyConverter(model.exchange_rates) + self.display_currency = display_currency or model.base_currency def analyze(self, months: int = 12) -> dict: forecast_service = ForecastService(self.model) @@ -22,6 +29,8 @@ class AssistantService: model_json=json.dumps(self.model.to_dict(), indent=2, ensure_ascii=False), forecast_json=json.dumps(forecast_result, indent=2, ensure_ascii=False), months=months, + base_currency=self.model.base_currency, + display_currency=self.display_currency, ) return { @@ -39,6 +48,8 @@ class AssistantService: model_json=json.dumps(self.model.to_dict(), indent=2, ensure_ascii=False), forecast_json=json.dumps(forecast_result, indent=2, ensure_ascii=False), question=question, + base_currency=self.model.base_currency, + display_currency=self.display_currency, ) return { @@ -49,6 +60,8 @@ class AssistantService: def compare_scenarios(self, scenarios_json: str) -> dict: prompt = prompts.SCENARIO_COMPARISON_PROMPT.format( scenarios_json=scenarios_json, + base_currency=self.model.base_currency, + display_currency=self.display_currency, ) return { "prompt": prompt, diff --git a/ai/prompts.py b/ai/prompts.py index 7734abe..f59d787 100644 --- a/ai/prompts.py +++ b/ai/prompts.py @@ -7,6 +7,10 @@ ANALYZE_PROMPT = """ ### Прогноз на {months} месяцев: {forecast_json} +### Валюта: +Базовая валюта модели: {base_currency} +Отображаемая валюта: {display_currency} + Дай анализ по пунктам: 1. Общее финансовое состояние 2. Тренд денежного потока (рост/падение) @@ -23,6 +27,10 @@ ADVICE_PROMPT = """ ### Прогноз: {forecast_json} +### Валюта: +Базовая валюта модели: {base_currency} +Отображаемая валюта: {display_currency} + Вопрос пользователя: {question} Ответь как опытный финансовый консультант. @@ -34,13 +42,25 @@ SCENARIO_COMPARISON_PROMPT = """ ### Результаты сценариев: {scenarios_json} +### Валюта: +Базовая валюта модели: {base_currency} +Отображаемая валюта: {display_currency} + Дай рекомендацию: какой сценарий наиболее вероятен и почему. """ -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( model_json=model_json, forecast_json=forecast_json, months=months, + base_currency=base_currency, + display_currency=display_currency, ) diff --git a/cashflow_model/__init__.py b/cashflow_model/__init__.py index 89c7fb4..033ab9b 100644 --- a/cashflow_model/__init__.py +++ b/cashflow_model/__init__.py @@ -1,5 +1,6 @@ from cashflow_model.account import Account 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.model import FinancialModel from cashflow_model.recurring import RecurringCashflow @@ -14,4 +15,8 @@ __all__ = [ "Liability", "ForecastScenario", "FinancialModel", + "ExchangeRate", + "CurrencyConverter", + "CurrencyError", + "CURRENCY_SYMBOLS", ] diff --git a/cashflow_model/currency.py b/cashflow_model/currency.py new file mode 100644 index 0000000..1a0ea53 --- /dev/null +++ b/cashflow_model/currency.py @@ -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) diff --git a/cashflow_model/model.py b/cashflow_model/model.py index 109e366..77d97d2 100644 --- a/cashflow_model/model.py +++ b/cashflow_model/model.py @@ -4,6 +4,7 @@ from pathlib import Path from cashflow_model.account import Account from cashflow_model.asset import Asset +from cashflow_model.currency import DEFAULT_RATES, ExchangeRate from cashflow_model.liability import Liability from cashflow_model.recurring import RecurringCashflow from cashflow_model.scenario import ForecastScenario @@ -12,32 +13,38 @@ from cashflow_model.transaction import Transaction @dataclass class FinancialModel: + base_currency: str = "RUB" accounts: list[Account] = field(default_factory=list) transactions: list[Transaction] = field(default_factory=list) recurring: list[RecurringCashflow] = field(default_factory=list) assets: list[Asset] = field(default_factory=list) liabilities: list[Liability] = field(default_factory=list) scenarios: list[ForecastScenario] = field(default_factory=list) + exchange_rates: list[ExchangeRate] = field(default_factory=lambda: DEFAULT_RATES.copy()) def to_dict(self) -> dict: return { + "base_currency": self.base_currency, "accounts": [a.to_dict() for a in self.accounts], "transactions": [t.to_dict() for t in self.transactions], "recurring": [r.to_dict() for r in self.recurring], "assets": [a.to_dict() for a in self.assets], "liabilities": [li.to_dict() for li in self.liabilities], "scenarios": [s.to_dict() for s in self.scenarios], + "exchange_rates": [r.to_dict() for r in self.exchange_rates], } @classmethod def from_dict(cls, data: dict) -> "FinancialModel": return cls( + base_currency=data.get("base_currency", "RUB"), accounts=[Account.from_dict(a) for a in data.get("accounts", [])], transactions=[Transaction.from_dict(t) for t in data.get("transactions", [])], recurring=[RecurringCashflow.from_dict(r) for r in data.get("recurring", [])], assets=[Asset.from_dict(a) for a in data.get("assets", [])], liabilities=[Liability.from_dict(li) for li in data.get("liabilities", [])], scenarios=[ForecastScenario.from_dict(s) for s in data.get("scenarios", [])], + exchange_rates=[ExchangeRate.from_dict(r) for r in data.get("exchange_rates", [])], ) def save(self, path: str | Path) -> None: diff --git a/cli/config.py b/cli/config.py new file mode 100644 index 0000000..e3a44d0 --- /dev/null +++ b/cli/config.py @@ -0,0 +1,419 @@ +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, +) + +app = typer.Typer(name="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="Код валюты (RUB, USD, EUR...)"), +) -> None: + """Показать или установить базовую валюту модели""" + model = _load_model() + if currency: + model.base_currency = currency.upper() + _save_model(model) + console.print(f"[green]OK[/green] Базовая валюта установлена: {model.base_currency}") + else: + console.print(f"Базовая валюта: [bold]{model.base_currency}[/bold]") + + +# ── rate ─────────────────────────────────────────────────────── + + +@app.command() +def rate_set( + from_currency: str = typer.Argument(..., help="Из валюты"), + to_currency: str = typer.Argument(..., help="В валюту"), + rate: float = typer.Argument(..., help="Курс"), +) -> 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]OK[/green] Курс {fc} → {tc} = {rate}") + + +@app.command(name="rate-list") +def rate_list() -> None: + """Список курсов валют""" + model = _load_model() + if not model.exchange_rates: + console.print("[yellow]Курсы не заданы[/yellow]") + return + table = Table(title="Курсы валют") + table.add_column("Из", style="cyan") + table.add_column("В", style="cyan") + table.add_column("Курс", 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="Из валюты"), + to_currency: str = typer.Argument(..., help="В валюту"), +) -> 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]OK[/green] Курс {fc} → {tc} удалён") + else: + console.print(f"[red]Курс {fc} → {tc} не найден[/red]") + raise typer.Exit(1) + + +# ── account ──────────────────────────────────────────────────── + + +@app.command() +def account_add( + name: str = typer.Option(..., "--name", "-n", help="Название счёта"), + balance: float = typer.Option(0.0, "--balance", "-b", help="Начальный баланс"), + currency: str = typer.Option("", "--currency", "-c", help="Валюта счёта"), +) -> 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) + console.print(f"[green]OK[/green] Счёт '{name}' добавлен (баланс: {balance}, валюта: {curr})") + + +@app.command(name="account-list") +def account_list() -> None: + """Список счетов""" + model = _load_model() + if not model.accounts: + console.print("[yellow]Счета не добавлены[/yellow]") + return + table = Table(title="Счета") + table.add_column("ID", style="dim") + table.add_column("Название", style="cyan") + table.add_column("Валюта") + table.add_column("Баланс", 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="ID или название счёта"), +) -> 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]OK[/green] Счёт '{identifier}' удалён") + else: + console.print(f"[red]Счёт '{identifier}' не найден[/red]") + raise typer.Exit(1) + + +# ── transaction ──────────────────────────────────────────────── + + +@app.command() +def transaction_add( + account: str = typer.Option(..., "--account", "-a", help="ID или название счёта"), + amount: float = typer.Option(..., "--amount", "-m", help="Сумма (доход/расход)"), + category: str = typer.Option("", "--category", "-c", help="Категория"), + description: str = typer.Option("", "--description", "-d", help="Описание"), + date: str = typer.Option("", "--date", "-D", help="Дата (YYYY-MM-DD)"), +) -> None: + """Добавить транзакцию""" + model = _load_model() + account_id = _resolve_account_id(model, account) + if not account_id: + console.print(f"[red]Счёт '{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 = "доход" if amount >= 0 else "расход" + console.print(f"[green]OK[/green] Транзакция '{kind}' на {abs(amount):.2f} (счёт: {account})") + + +@app.command(name="transaction-list") +def transaction_list() -> None: + """Список транзакций""" + model = _load_model() + if not model.transactions: + console.print("[yellow]Транзакции не добавлены[/yellow]") + return + table = Table(title="Транзакции") + table.add_column("ID", style="dim") + table.add_column("Дата") + table.add_column("Счёт") + table.add_column("Категория") + table.add_column("Сумма", justify="right") + table.add_column("Описание") + for t in model.transactions: + account_name = _account_name(model, t.account) + table.add_row( + str(t.id)[:8], t.date, account_name, t.category, + f"{t.amount:,.2f}", t.description, + ) + console.print(table) + + +@app.command(name="transaction-remove") +def transaction_remove( + identifier: str = typer.Argument(..., help="ID транзакции"), +) -> 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]OK[/green] Транзакция '{identifier}' удалена") + else: + console.print(f"[red]Транзакция '{identifier}' не найдена[/red]") + raise typer.Exit(1) + + +# ── recurring ────────────────────────────────────────────────── + + +@app.command() +def recurring_add( + amount: float = typer.Option(..., "--amount", "-m", help="Сумма"), + category: str = typer.Option("", "--category", "-c", help="Категория"), + frequency: str = typer.Option("monthly", "--frequency", "-f", help="Периодичность"), + start_date: str = typer.Option("", "--start", "-s", help="Дата начала (YYYY-MM-DD)"), + end_date: str = typer.Option("", "--end", "-e", help="Дата окончания (YYYY-MM-DD)"), +) -> 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) + console.print(f"[green]OK[/green] Регулярный платёж на {abs(amount):.2f} ({frequency})") + + +@app.command(name="recurring-list") +def recurring_list() -> None: + """Список регулярных платежей""" + model = _load_model() + if not model.recurring: + console.print("[yellow]Регулярные платежи не добавлены[/yellow]") + return + table = Table(title="Регулярные платежи") + table.add_column("ID", style="dim") + table.add_column("Начало") + table.add_column("Конец") + table.add_column("Период") + table.add_column("Сумма", justify="right") + table.add_column("Категория") + 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="ID регулярного платежа"), +) -> 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]OK[/green] Регулярный платёж '{identifier}' удалён") + else: + console.print(f"[red]Регулярный платёж '{identifier}' не найден[/red]") + raise typer.Exit(1) + + +# ── asset ────────────────────────────────────────────────────── + + +@app.command() +def asset_add( + name: str = typer.Option(..., "--name", "-n", help="Название актива"), + value: float = typer.Option(0.0, "--value", "-v", help="Стоимость"), + growth_rate: float = typer.Option(0.0, "--growth", "-g", help="Годовой рост (%)"), +) -> 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]OK[/green] Актив '{name}' добавлен (стоимость: {value})") + + +@app.command(name="asset-list") +def asset_list() -> None: + """Список активов""" + model = _load_model() + if not model.assets: + console.print("[yellow]Активы не добавлены[/yellow]") + return + table = Table(title="Активы") + table.add_column("ID", style="dim") + table.add_column("Название", style="cyan") + table.add_column("Стоимость", justify="right") + table.add_column("Рост (%)", 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="ID или название актива"), +) -> 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]OK[/green] Актив '{identifier}' удалён") + else: + console.print(f"[red]Актив '{identifier}' не найден[/red]") + raise typer.Exit(1) + + +# ── liability ────────────────────────────────────────────────── + + +@app.command() +def liability_add( + name: str = typer.Option(..., "--name", "-n", help="Название"), + balance: float = typer.Option(0.0, "--balance", "-b", help="Остаток долга"), + interest: float = typer.Option(0.0, "--interest", "-i", help="Процентная ставка (%)"), + payment: float = typer.Option(0.0, "--payment", "-p", help="Ежемесячный платёж"), +) -> 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]OK[/green] Обязательство '{name}' добавлено (долг: {balance})") + + +@app.command(name="liability-list") +def liability_list() -> None: + """Список обязательств""" + model = _load_model() + if not model.liabilities: + console.print("[yellow]Обязательства не добавлены[/yellow]") + return + table = Table(title="Обязательства") + table.add_column("ID", style="dim") + table.add_column("Название", style="cyan") + table.add_column("Долг", justify="right") + table.add_column("Ставка", justify="right") + table.add_column("Платёж", 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="ID или название обязательства"), +) -> 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]OK[/green] Обязательство '{identifier}' удалено") + else: + console.print(f"[red]Обязательство '{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] diff --git a/cli/main.py b/cli/main.py index 69c3330..e5a15f2 100644 --- a/cli/main.py +++ b/cli/main.py @@ -6,7 +6,8 @@ from rich.console import Console from rich.table import Table 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 engine.forecast import ForecastService from engine.scenarios import DEFAULT_SCENARIOS, ScenarioService from sync.excel_sync import ExcelSync @@ -22,6 +23,9 @@ console = Console() DATA_DIR = Path("data") MODEL_PATH = DATA_DIR / "model.json" +CURRENCY_HELP = "Валюта отображения (по умолчанию — базовая валюта модели)" +CURRENCY_OPTION = typer.Option(None, "--currency", "-c", help=CURRENCY_HELP) + def _load_model() -> FinancialModel: if MODEL_PATH.exists(): @@ -33,26 +37,59 @@ 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("[green]OK[/green] Пустая модель создана в data/model.json") + console.print(f"[green]OK[/green] Пустая модель создана в {MODEL_PATH}") @app.command() def forecast( months: int = typer.Option(12, "--months", "-m", help="Количество месяцев прогноза"), + 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=f"Прогноз на {months} мес.") + table = Table(title=f"Прогноз на {months} мес. ({target_curr})") table.add_column("Счёт", style="cyan") table.add_column("Месяц", style="white") table.add_column("Баланс", justify="right", style="green") @@ -62,24 +99,29 @@ def forecast( for r in results: table.add_row( r["account"], str(r["month"]), - f"${r['balance']:.2f}", - f"${r['income']:.2f}", - f"${r['expenses']:.2f}", + _f(_cv(r["balance"])), + _f(_cv(r["income"])), + _f(_cv(r["expenses"])), ) console.print(table) - console.print(f"\n[bold]Итог:[/bold] Баланс: ${summary['total_balance']:.2f} | " - f"Доход: ${summary['total_income']:.2f} | " - f"Расход: ${summary['total_expenses']:.2f}") + cb = _cv(summary["total_balance"]) + ci = _cv(summary["total_income"]) + ce = _cv(summary["total_expenses"]) + console.print(f"\n[bold]Итог:[/bold] Баланс: {_f(cb)} | Доход: {_f(ci)} | Расход: {_f(ce)}") @app.command() def scenario( name: str = typer.Argument("baseline", help="Имя сценария: baseline, optimistic, pessimistic"), months: int = typer.Option(12, "--months", "-m", help="Количество месяцев"), + 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: @@ -90,11 +132,14 @@ def scenario( 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]Сценарий:[/bold] {result['scenario']}") console.print(f"[dim]{result['scenario_description']}[/dim]") - console.print(f"Баланс: ${result['total_balance']:.2f}") - console.print(f"Доход: ${result['total_income']:.2f}") - console.print(f"Расход: ${result['total_expenses']:.2f}") + console.print(f"Баланс: {_fmt(balance, target_curr, symbol)}") + console.print(f"Доход: {_fmt(income, target_curr, symbol)}") + console.print(f"Расход: {_fmt(expenses, target_curr, symbol)}") @app.command() @@ -103,41 +148,53 @@ def whatif( expense_mult: float = typer.Option(1.0, "--expense", "-e", help="Множитель расхода"), growth_mult: float = typer.Option(1.0, "--growth", "-g", help="Множитель роста активов"), months: int = typer.Option(12, "--months", "-m", help="Количество месяцев"), + 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("[bold]What-if анализ[/bold]") console.print(f"Доход x{income_mult} | Расход x{expense_mult} | Рост x{growth_mult}") - console.print(f"Баланс: ${result['total_balance']:.2f}") - console.print(f"Доход: ${result['total_income']:.2f}") - console.print(f"Расход: ${result['total_expenses']:.2f}") + console.print(f"Баланс: {_fmt(balance, target_curr, symbol)}") + console.print(f"Доход: {_fmt(income, target_curr, symbol)}") + console.print(f"Расход: {_fmt(expenses, target_curr, symbol)}") @app.command() def compare( months: int = typer.Option(12, "--months", "-m", help="Количество месяцев"), + 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="Сравнение сценариев") + table = Table(title=f"Сравнение сценариев ({target_curr})") table.add_column("Сценарий", style="cyan") table.add_column("Баланс", justify="right") table.add_column("Доход", justify="right") table.add_column("Расход", justify="right") for name, r in results.items(): - table.add_row( - name, - f"${r['total_balance']:.2f}", - f"${r['total_income']:.2f}", - f"${r['total_expenses']:.2f}", - ) + 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) @@ -155,6 +212,7 @@ def import_xlsx( f"{len(model.recurring)} регулярных платежей, " f"{len(model.assets)} активов, " f"{len(model.liabilities)} обязательств") + console.print(f"[dim]Базовая валюта: {model.base_currency}[/dim]") except Exception as e: console.print(f"[red]Ошибка импорта: {e}[/red]") raise typer.Exit(1) @@ -178,24 +236,94 @@ def export_xlsx( @app.command() def analyze( months: int = typer.Option(12, "--months", "-m", help="Количество месяцев для анализа"), + currency: str | None = CURRENCY_OPTION, ) -> None: """AI-анализ финансовой модели""" 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) console.print("[bold]Промпт для AI:[/bold]") console.print(result["prompt"][:500] + "...\n") console.print("[bold]Сводка:[/bold]") - s = result["summary"] - console.print(f"Баланс: ${s['total_balance']:.2f}") - console.print(f"Доход: ${s['total_income']:.2f}") - console.print(f"Расход: ${s['total_expenses']:.2f}") + console.print(f"Базовая валюта: {model.base_currency}") 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("[bold]Метаданные[/bold]") + console.print(f" Базовая валюта: {model.base_currency} ({symbol})") + console.print(f" Счетов: {len(model.accounts)}") + console.print(f" Транзакций: {len(model.transactions)}") + console.print(f" Регулярных платежей: {len(model.recurring)}") + console.print(f" Активов: {len(model.assets)}") + console.print(f" Обязательств: {len(model.liabilities)}") + console.print(f" Курсов валют: {len(model.exchange_rates)}") + + if model.accounts: + t = Table(title="Счета") + t.add_column("Название", style="cyan") + t.add_column("Валюта") + t.add_column("Баланс", justify="right") + for a in model.accounts: + t.add_row(a.name, a.currency, f"{a.balance:,.2f}") + console.print(t) + + if model.transactions: + t = Table(title="Транзакции") + t.add_column("Дата") + t.add_column("Категория") + t.add_column("Сумма", justify="right") + t.add_column("Описание") + for tx in model.transactions[:10]: + t.add_row(tx.date, tx.category, f"{tx.amount:,.2f}", tx.description) + if len(model.transactions) > 10: + console.print(t) + console.print(f"[dim]... и ещё {len(model.transactions) - 10} транзакций[/dim]") + else: + console.print(t) + + if model.recurring: + t = Table(title="Регулярные платежи") + t.add_column("Период") + t.add_column("Сумма", justify="right") + t.add_column("Категория") + for r in model.recurring: + t.add_row(r.frequency, f"{r.amount:,.2f}", r.category) + console.print(t) + + if model.assets: + t = Table(title="Активы") + t.add_column("Название", style="cyan") + t.add_column("Стоимость", justify="right") + t.add_column("Рост", justify="right") + for a in model.assets: + t.add_row(a.name, f"{a.value:,.2f}", f"{a.growth_rate:.1f}%") + console.print(t) + + if model.liabilities: + t = Table(title="Обязательства") + t.add_column("Название", style="cyan") + t.add_column("Долг", justify="right") + t.add_column("Ставка", justify="right") + t.add_column("Платёж", justify="right") + for li in model.liabilities: + t.add_row(li.name, f"{li.balance:,.2f}", f"{li.interest:.1f}%", f"{li.payment:,.2f}") + console.print(t) + + +app.add_typer(config_app) + if __name__ == "__main__": app() diff --git a/data/model.json b/data/model.json index c6b8eb4..e6789ed 100644 --- a/data/model.json +++ b/data/model.json @@ -1,8 +1,204 @@ { - "accounts": [], - "transactions": [], - "recurring": [], - "assets": [], - "liabilities": [], - "scenarios": [] + "base_currency": "RUB", + "accounts": [ + { + "id": "95f9958d-f6e9-41d5-9479-fd27af0e0af0", + "name": "Основной счёт", + "currency": "RUB", + "balance": 15000.0 + }, + { + "id": "a166f13d-7b44-4735-8283-d5dcfbfe4ab6", + "name": "Наличные", + "currency": "RUB", + "balance": 5000.0 + } + ], + "transactions": [ + { + "id": "ec0d7e89-b337-4d58-b682-0cff41582dd8", + "date": "2026-07-05", + "account": "95f9958d-f6e9-41d5-9479-fd27af0e0af0", + "category": "Зарплата", + "amount": 30000.0, + "description": "Зарплата июль 2026" + }, + { + "id": "151f0802-ab65-4aee-ae18-3142dd91e936", + "date": "2026-07-01", + "account": "95f9958d-f6e9-41d5-9479-fd27af0e0af0", + "category": "Аренда", + "amount": -10000.0, + "description": "Аренда за июль" + }, + { + "id": "e64a4277-1add-48de-aacf-e33b8ad3703a", + "date": "2026-07-10", + "account": "95f9958d-f6e9-41d5-9479-fd27af0e0af0", + "category": "Коммуналка", + "amount": -3200.0, + "description": "ЖКХ июль" + }, + { + "id": "8746454f-3fae-4ca5-a641-6dc92337dd80", + "date": "2026-07-03", + "account": "95f9958d-f6e9-41d5-9479-fd27af0e0af0", + "category": "Продукты", + "amount": -2500.0, + "description": "Магнит" + }, + { + "id": "17381c61-d17a-4eed-8976-e02ae70e06be", + "date": "2026-07-12", + "account": "95f9958d-f6e9-41d5-9479-fd27af0e0af0", + "category": "Продукты", + "amount": -3500.0, + "description": "Пятёрочка" + }, + { + "id": "17c0abc9-b889-42e3-aaab-185e82ff37a4", + "date": "2026-07-20", + "account": "95f9958d-f6e9-41d5-9479-fd27af0e0af0", + "category": "Продукты", + "amount": -2000.0, + "description": "Ашан" + }, + { + "id": "6bb03cdb-c559-4302-b42b-bf17b1d98fe7", + "date": "2026-07-01", + "account": "a166f13d-7b44-4735-8283-d5dcfbfe4ab6", + "category": "Транспорт", + "amount": -2000.0, + "description": "Проездные/метро" + }, + { + "id": "ae47a80a-7de6-43f0-89ad-5bc895bdc144", + "date": "2026-07-08", + "account": "a166f13d-7b44-4735-8283-d5dcfbfe4ab6", + "category": "Телефон", + "amount": -500.0, + "description": "МТС" + }, + { + "id": "d9ad51f2-af7e-4de0-a958-a5196b612c62", + "date": "2026-07-15", + "account": "95f9958d-f6e9-41d5-9479-fd27af0e0af0", + "category": "Подписки", + "amount": -500.0, + "description": "Яндекс Плюс" + }, + { + "id": "c0a14822-0079-4bb6-8e26-1fe759de1c36", + "date": "2026-07-18", + "account": "95f9958d-f6e9-41d5-9479-fd27af0e0af0", + "category": "Досуг", + "amount": -1500.0, + "description": "Кино" + }, + { + "id": "cc0918e8-3313-468e-b54b-159ee42209a5", + "date": "2026-07-22", + "account": "95f9958d-f6e9-41d5-9479-fd27af0e0af0", + "category": "Досуг", + "amount": -500.0, + "description": "Кофе с друзьями" + } + ], + "recurring": [ + { + "id": "d12d05ff-beaa-43ff-a5c7-1527a28efbc7", + "start_date": "2026-01-01", + "end_date": "", + "frequency": "monthly", + "amount": 30000.0, + "category": "Зарплата" + }, + { + "id": "f2325ca7-e433-4788-80ca-20d092fd3543", + "start_date": "2026-01-01", + "end_date": "", + "frequency": "monthly", + "amount": -10000.0, + "category": "Аренда" + }, + { + "id": "f413ccc6-efd2-4392-a2d8-dbe972019903", + "start_date": "2026-01-01", + "end_date": "", + "frequency": "monthly", + "amount": -3000.0, + "category": "Коммуналка" + }, + { + "id": "c6a89bc6-b91b-4c74-bb15-f3ecda429f7d", + "start_date": "2026-01-01", + "end_date": "", + "frequency": "monthly", + "amount": -8000.0, + "category": "Продукты" + }, + { + "id": "8130d5c8-861a-429f-95f4-c9a8291bce9c", + "start_date": "2026-01-01", + "end_date": "", + "frequency": "monthly", + "amount": -2000.0, + "category": "Транспорт" + }, + { + "id": "bb5c9843-cbc5-436d-b22e-c7cf5bf007f3", + "start_date": "2026-01-01", + "end_date": "", + "frequency": "monthly", + "amount": -500.0, + "category": "Телефон" + }, + { + "id": "876929b0-5d70-42b3-b803-7214906c1d25", + "start_date": "2026-01-01", + "end_date": "", + "frequency": "monthly", + "amount": -500.0, + "category": "Подписки" + }, + { + "id": "91938e4b-cf41-4366-8121-897958ad71be", + "start_date": "2026-01-01", + "end_date": "", + "frequency": "monthly", + "amount": -3000.0, + "category": "Досуг" + } + ], + "assets": [ + { + "id": "15216cc8-ef46-47d4-9184-9b3cc0e57518", + "name": "НЗ (подушка безопасности)", + "value": 50000.0, + "growth_rate": 0.0 + }, + { + "id": "71e4c3bb-ff6f-475a-9502-395e83455db8", + "name": "Вклад в банке", + "value": 100000.0, + "growth_rate": 10.0 + } + ], + "liabilities": [ + { + "id": "87053a69-5d31-4600-978e-f9941c6ac4d6", + "name": "Кредитная карта", + "balance": 8000.0, + "interest": 25.0, + "payment": 1000.0 + } + ], + "scenarios": [], + "exchange_rates": [ + { + "from_currency": "USD", + "to_currency": "RUB", + "rate": 80.0 + } + ] } \ No newline at end of file diff --git a/data/model.xlsx b/data/model.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..ae307e7d55647015dc458de9f0abb65ed2176bea GIT binary patch literal 7872 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 literal 0 HcmV?d00001 diff --git a/sync/excel_sync.py b/sync/excel_sync.py index 8525b01..f8ebe05 100644 --- a/sync/excel_sync.py +++ b/sync/excel_sync.py @@ -3,7 +3,15 @@ from uuid import UUID 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): @@ -31,6 +39,10 @@ _SHEET_CONFIG = { "fields": ["id", "name", "balance", "interest", "payment"], "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) model = FinancialModel() + model.exchange_rates = [] for sheet_name, config in _SHEET_CONFIG.items(): 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 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() return model @@ -76,6 +96,7 @@ class ExcelSync: "Recurring": model.recurring, "Assets": model.assets, "Liabilities": model.liabilities, + "ExchangeRates": model.exchange_rates, } for sheet_name, items in collections.items(): @@ -90,6 +111,10 @@ class ExcelSync: ] 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) 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)) elif sheet_name == "Liabilities": model.liabilities.append(Liability.from_dict(data)) + elif sheet_name == "ExchangeRates": + model.exchange_rates.append(ExchangeRate.from_dict(data)) except Exception as e: raise SyncError(f"Failed to parse row in {sheet_name}: {e}") from e diff --git a/tests/conftest.py b/tests/conftest.py index 0038e23..cd8fd7a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -5,6 +5,8 @@ import pytest from cashflow_model import ( Account, Asset, + CurrencyConverter, + ExchangeRate, FinancialModel, Liability, RecurringCashflow, @@ -16,9 +18,10 @@ from cashflow_model import ( def sample_model() -> FinancialModel: acc_id = uuid4() return FinancialModel( + base_currency="RUB", accounts=[ - Account(id=acc_id, name="Основной счёт", currency="USD", balance=5000.0), - Account(name="Сбережения", currency="USD", balance=10000.0), + Account(id=acc_id, name="Основной счёт", currency="RUB", balance=5000.0), + Account(name="Сбережения", currency="RUB", balance=10000.0), ], transactions=[ Transaction( @@ -48,9 +51,17 @@ def sample_model() -> FinancialModel: liabilities=[ 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 def empty_model() -> FinancialModel: return FinancialModel() + + +@pytest.fixture +def sample_converter() -> CurrencyConverter: + return CurrencyConverter([ExchangeRate(from_currency="USD", to_currency="RUB", rate=80.0)]) diff --git a/tests/test_cli.py b/tests/test_cli.py index 5c1a63c..47cbf7b 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,21 +1,35 @@ from typer.testing import CliRunner -from cli.main import app +from cli.main import MODEL_PATH, app runner = CliRunner() +def _cleanup(): + if MODEL_PATH.exists(): + MODEL_PATH.unlink() + + class TestCli: def test_init(self): + _cleanup() result = runner.invoke(app, ["init"]) assert result.exit_code == 0 assert "Пустая модель" in result.stdout def test_forecast_after_init(self): + _cleanup() runner.invoke(app, ["init"]) result = runner.invoke(app, ["forecast", "--months", "3"]) 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): result = runner.invoke(app, ["scenario", "unknown"]) assert result.exit_code != 0 @@ -23,3 +37,132 @@ class TestCli: def test_help(self): result = runner.invoke(app, ["--help"]) 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 diff --git a/tests/test_currency.py b/tests/test_currency.py new file mode 100644 index 0000000..ced7f69 --- /dev/null +++ b/tests/test_currency.py @@ -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 diff --git a/tests/test_excel_sync.py b/tests/test_excel_sync.py index 1c01d8b..9cb952c 100644 --- a/tests/test_excel_sync.py +++ b/tests/test_excel_sync.py @@ -22,6 +22,16 @@ class TestExcelSync: assert len(loaded.recurring) == len(sample_model.recurring) assert len(loaded.assets) == len(sample_model.assets) 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): sync = ExcelSync() diff --git a/tests/test_model.py b/tests/test_model.py index 0d61fa2..250d826 100644 --- a/tests/test_model.py +++ b/tests/test_model.py @@ -3,6 +3,7 @@ from pathlib import Path from cashflow_model import ( Account, Asset, + ExchangeRate, FinancialModel, ForecastScenario, Liability, @@ -42,6 +43,32 @@ class TestTransaction: 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): model = FinancialModel() model.accounts.append(Account(name="Test", balance=100.0)) @@ -61,6 +88,8 @@ class TestFinancialModel: d = model.to_dict() assert d["accounts"] == [] assert d["transactions"] == [] + assert d["base_currency"] == "RUB" + assert "exchange_rates" in d def test_all_entities_roundtrip(self, tmp_path: Path): model = FinancialModel( @@ -70,6 +99,7 @@ class TestFinancialModel: assets=[Asset(name="Stock", value=1000.0)], liabilities=[Liability(name="Loan", balance=500.0, interest=5.0, payment=100.0)], scenarios=[ForecastScenario(name="test")], + exchange_rates=[ExchangeRate(from_currency="USD", to_currency="RUB", rate=80.0)], ) p = tmp_path / "full.json" model.save(p) @@ -80,3 +110,4 @@ class TestFinancialModel: assert len(loaded.assets) == 1 assert len(loaded.liabilities) == 1 assert len(loaded.scenarios) == 1 + assert len(loaded.exchange_rates) == 1