diff --git a/application/__init__.py b/application/__init__.py new file mode 100644 index 0000000..4cebe7d --- /dev/null +++ b/application/__init__.py @@ -0,0 +1,10 @@ +from application.forecast import ForecastError, ForecastService +from application.scenarios import DEFAULT_SCENARIOS, ScenarioError, ScenarioService + +__all__ = [ + "ForecastService", + "ForecastError", + "ScenarioService", + "ScenarioError", + "DEFAULT_SCENARIOS", +] diff --git a/application/forecast.py b/application/forecast.py new file mode 100644 index 0000000..213f469 --- /dev/null +++ b/application/forecast.py @@ -0,0 +1,103 @@ +from copy import deepcopy + +from domain import Account, FinancialModel + + +class ForecastError(Exception): + pass + + +class ForecastService: + def __init__(self, model: FinancialModel): + self.model = deepcopy(model) + + def forecast_cashflow(self, months: int = 12) -> list[dict]: + if months < 1: + raise ForecastError("months must be >= 1") + + results = [] + for account in self.model.accounts: + monthly = self._project_account(account, months) + for m in range(months): + results.append({ + "account": account.name, + "month": m + 1, + "balance": monthly[m]["balance"], + "income": monthly[m]["income"], + "expenses": monthly[m]["expenses"], + }) + + # Asset growth — once per month, distributed across accounts proportionally + for m in range(months): + total_growth = sum( + a.value * a.growth_rate / 100 / 12 + for a in self.model.assets + ) + month_rows = [r for r in results if r["month"] == m + 1] + total_bal = sum(r["balance"] for r in month_rows) or 1 + for r in month_rows: + share = r["balance"] / total_bal + r["income"] = round(r["income"] + total_growth * share, 2) + r["balance"] = round(r["balance"] + total_growth * share, 2) + + # Compound asset values for next month + for a in self.model.assets: + a.value += a.value * a.growth_rate / 100 / 12 + + return results + + def _project_account(self, account: Account, months: int) -> list[dict]: + balance = account.balance + monthly = [] + for m in range(months): + income = 0.0 + expenses = 0.0 + + for t in self.model.transactions: + if t.account == str(account.id): + if t.amount > 0: + income += t.amount + else: + expenses += abs(t.amount) + + for r in self.model.recurring: + if r.amount > 0: + income += r.amount + else: + expenses += abs(r.amount) + + expenses += self._liability_cost(account) + + balance += income - expenses + + monthly.append({ + "balance": round(balance, 2), + "income": round(income, 2), + "expenses": round(expenses, 2), + }) + return monthly + + def _liability_cost(self, account: Account) -> float: + total = 0.0 + for liability in self.model.liabilities: + interest_cost = liability.balance * liability.interest / 100 / 12 + total += interest_cost + liability.balance -= liability.payment - interest_cost + if liability.balance < 0: + liability.balance = 0 + return total + + def summary(self, months: int = 12) -> dict: + results = self.forecast_cashflow(months) + if not results: + return {"total_balance": 0, "total_income": 0, "total_expenses": 0, "months": months} + + final = results[-1] + all_income = sum(r["income"] for r in results) + all_expenses = sum(r["expenses"] for r in results) + return { + "total_balance": final["balance"], + "total_income": round(all_income, 2), + "total_expenses": round(all_expenses, 2), + "months": months, + } diff --git a/application/scenarios.py b/application/scenarios.py new file mode 100644 index 0000000..8b0f2a0 --- /dev/null +++ b/application/scenarios.py @@ -0,0 +1,87 @@ +from copy import deepcopy + +from domain import FinancialModel, ForecastScenario +from application.forecast import ForecastService + + +class ScenarioError(Exception): + pass + + +DEFAULT_SCENARIOS = { + "baseline": ForecastScenario( + name="baseline", + income_multiplier=1.0, + expense_multiplier=1.0, + growth_multiplier=1.0, + description="Базовый сценарий без изменений", + ), + "optimistic": ForecastScenario( + name="optimistic", + income_multiplier=1.15, + expense_multiplier=0.95, + growth_multiplier=1.2, + description="Оптимистичный: доход +15%, расход -5%, рост активов +20%", + ), + "pessimistic": ForecastScenario( + name="pessimistic", + income_multiplier=0.85, + expense_multiplier=1.1, + growth_multiplier=0.8, + description="Пессимистичный: доход -15%, расход +10%, рост активов -20%", + ), +} + + +class ScenarioService: + def __init__(self, model: FinancialModel): + self.model = deepcopy(model) + + def apply(self, scenario: ForecastScenario, months: int = 12) -> dict: + model = deepcopy(self.model) + + for t in model.transactions: + if t.amount > 0: + t.amount *= scenario.income_multiplier + else: + t.amount *= scenario.expense_multiplier + + for r in model.recurring: + if r.category == "income": + r.amount *= scenario.income_multiplier + else: + r.amount *= scenario.expense_multiplier + + for a in model.assets: + a.growth_rate *= scenario.growth_multiplier + + service = ForecastService(model) + result = service.summary(months) + result["scenario"] = scenario.name + result["scenario_description"] = scenario.description + return result + + def compare(self, months: int = 12) -> dict: + results = {} + for name, scenario in DEFAULT_SCENARIOS.items(): + results[name] = self.apply(scenario, months) + return results + + def what_if( + self, + income_mult: float = 1.0, + expense_mult: float = 1.0, + growth_mult: float = 1.0, + months: int = 12, + ) -> dict: + scenario = ForecastScenario( + name="what-if", + income_multiplier=income_mult, + expense_multiplier=expense_mult, + growth_multiplier=growth_mult, + description=( + f"What-if: income x{income_mult}, " + f"expense x{expense_mult}, growth x{growth_mult}" + ), + ) + return self.apply(scenario, months) diff --git a/data/model.json b/data/model.json index 8b8e979..4b6532b 100644 --- a/data/model.json +++ b/data/model.json @@ -1,97 +1,31 @@ -{ - "base_currency": "RUB", - "accounts": [ - { - "id": "cfff5fa0-d42d-46d8-81ec-f460234e4123", - "name": "Основной счёт", - "currency": "RUB", - "balance": 2000 - } - ], - "transactions": [ - ], - "recurring": [ - { - "id": "479a9960-d7ec-476b-9621-888ceaa1b671", - "start_date": "2026-01-01", - "end_date": "", - "frequency": "monthly", - "amount": 30000, - "category": "Зарплата" - }, - { - "id": "eb565346-679b-4560-89d1-7b0c80bbcb5a", - "start_date": "2026-01-01", - "end_date": "", - "frequency": "monthly", - "amount": -10000, - "category": "Аренда" - }, - { - "id": "30c168a3-cbe0-4d8e-a842-d430d98a80b6", - "start_date": "2026-01-01", - "end_date": "", - "frequency": "monthly", - "amount": -3000, - "category": "Коммуналка" - }, - { - "id": "f37ef9d3-76a8-4eb3-9a12-e20817d87079", - "start_date": "2026-01-01", - "end_date": "", - "frequency": "monthly", - "amount": -8000, - "category": "Продукты" - }, - { - "id": "f8ef073e-a8ca-4637-bd63-e4faeb1b282f", - "start_date": "2026-01-01", - "end_date": "", - "frequency": "monthly", - "amount": -2000, - "category": "Транспорт" - }, - { - "id": "ae8f6ad5-2c66-40d9-b102-64076e9be978", - "start_date": "2026-01-01", - "end_date": "", - "frequency": "monthly", - "amount": -500, - "category": "Телефон" - }, - { - "id": "fb1197de-727e-49db-9fb8-c5b2554b0504", - "start_date": "2026-01-01", - "end_date": "", - "frequency": "monthly", - "amount": -500, - "category": "Подписки" - }, - { - "id": "d8e3392a-4015-4920-b27c-e72f99e6bbb7", - "start_date": "2026-01-01", - "end_date": "", - "frequency": "monthly", - "amount": -3000, - "category": "Досуг" - } - ], - "assets": [ - { - "id": "bd5080c0-45a9-40b1-9dc4-c1ec41157bd7", - "name": "Вклад в банке", - "value": 35000, - "growth_rate": 12 - } - ], - "liabilities": [ - ], - "scenarios": [], - "exchange_rates": [ - { - "from_currency": "USD", - "to_currency": "RUB", - "rate": 80 - } - ] +{ + "version": 1, + "base_currency": "RUB", + "accounts": [ + { + "id": "0283653e-2ebd-48fe-8b64-90ead1804e60", + "name": "Основной", + "currency": "RUB", + "balance": 50000.0 + } + ], + "transactions": [], + "recurring": [], + "assets": [ + { + "id": "a4557779-4bb7-4b3b-b88e-1e436c3e6e43", + "name": "Акции", + "value": 100000.0, + "growth_rate": 8.0 + } + ], + "liabilities": [], + "scenarios": [], + "exchange_rates": [ + { + "from_currency": "USD", + "to_currency": "RUB", + "rate": 80.0 + } + ] } \ No newline at end of file diff --git a/domain/__init__.py b/domain/__init__.py new file mode 100644 index 0000000..41a6df5 --- /dev/null +++ b/domain/__init__.py @@ -0,0 +1,22 @@ +from domain.account import Account +from domain.asset import Asset +from domain.currency import CURRENCY_SYMBOLS, CurrencyConverter, CurrencyError, ExchangeRate +from domain.liability import Liability +from domain.model import FinancialModel +from domain.recurring import RecurringCashflow +from domain.scenario import ForecastScenario +from domain.transaction import Transaction + +__all__ = [ + "Account", + "Transaction", + "RecurringCashflow", + "Asset", + "Liability", + "ForecastScenario", + "FinancialModel", + "ExchangeRate", + "CurrencyConverter", + "CurrencyError", + "CURRENCY_SYMBOLS", +] diff --git a/domain/account.py b/domain/account.py new file mode 100644 index 0000000..f70ca25 --- /dev/null +++ b/domain/account.py @@ -0,0 +1,27 @@ +from dataclasses import dataclass, field +from uuid import UUID, uuid4 + + +@dataclass +class Account: + id: UUID = field(default_factory=uuid4) + name: str = "" + currency: str = "USD" + balance: float = 0.0 + + def to_dict(self) -> dict: + return { + "id": str(self.id), + "name": self.name, + "currency": self.currency, + "balance": self.balance, + } + + @classmethod + def from_dict(cls, data: dict) -> "Account": + return cls( + id=UUID(data["id"]), + name=data["name"], + currency=data.get("currency", "USD"), + balance=data.get("balance", 0.0), + ) diff --git a/domain/asset.py b/domain/asset.py new file mode 100644 index 0000000..e68f670 --- /dev/null +++ b/domain/asset.py @@ -0,0 +1,27 @@ +from dataclasses import dataclass, field +from uuid import UUID, uuid4 + + +@dataclass +class Asset: + id: UUID = field(default_factory=uuid4) + name: str = "" + value: float = 0.0 + growth_rate: float = 0.0 + + def to_dict(self) -> dict: + return { + "id": str(self.id), + "name": self.name, + "value": self.value, + "growth_rate": self.growth_rate, + } + + @classmethod + def from_dict(cls, data: dict) -> "Asset": + return cls( + id=UUID(data["id"]), + name=data["name"], + value=data.get("value", 0.0), + growth_rate=data.get("growth_rate", 0.0), + ) diff --git a/domain/currency.py b/domain/currency.py new file mode 100644 index 0000000..1a0ea53 --- /dev/null +++ b/domain/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/domain/liability.py b/domain/liability.py new file mode 100644 index 0000000..bdbaa01 --- /dev/null +++ b/domain/liability.py @@ -0,0 +1,30 @@ +from dataclasses import dataclass, field +from uuid import UUID, uuid4 + + +@dataclass +class Liability: + id: UUID = field(default_factory=uuid4) + name: str = "" + balance: float = 0.0 + interest: float = 0.0 + payment: float = 0.0 + + def to_dict(self) -> dict: + return { + "id": str(self.id), + "name": self.name, + "balance": self.balance, + "interest": self.interest, + "payment": self.payment, + } + + @classmethod + def from_dict(cls, data: dict) -> "Liability": + return cls( + id=UUID(data["id"]), + name=data["name"], + balance=data.get("balance", 0.0), + interest=data.get("interest", 0.0), + payment=data.get("payment", 0.0), + ) diff --git a/domain/model.py b/domain/model.py new file mode 100644 index 0000000..17c61ed --- /dev/null +++ b/domain/model.py @@ -0,0 +1,78 @@ +import json +from dataclasses import dataclass, field +from pathlib import Path + +from domain.account import Account +from domain.asset import Asset +from domain.currency import DEFAULT_RATES, ExchangeRate +from domain.liability import Liability +from domain.recurring import RecurringCashflow +from domain.scenario import ForecastScenario +from domain.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()) + + SCHEMA_VERSION = 1 + + def to_dict(self) -> dict: + return { + "version": self.SCHEMA_VERSION, + "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": + version = data.get("version", 0) + if version == 0: + return cls._from_v0(data) + if version == 1: + return cls._from_v1(data) + raise ValueError(f"Unsupported FinancialModel version: {version}") + + @classmethod + def _from_v1(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", [])], + ) + + @classmethod + def _from_v0(cls, data: dict) -> "FinancialModel": + # Legacy: файлы, сохранённые до введения version. Совпадает по структуре с v1. + return cls._from_v1(data) + + def save(self, path: str | Path) -> None: + path = Path(path) + path.parent.mkdir(parents=True, exist_ok=True) + with open(path, "w", encoding="utf-8") as f: + json.dump(self.to_dict(), f, indent=2, ensure_ascii=False) + + @classmethod + def load(cls, path: str | Path) -> "FinancialModel": + path = Path(path) + with open(path, "r", encoding="utf-8") as f: + data = json.load(f) + return cls.from_dict(data) diff --git a/domain/recurring.py b/domain/recurring.py new file mode 100644 index 0000000..6ca69f4 --- /dev/null +++ b/domain/recurring.py @@ -0,0 +1,33 @@ +from dataclasses import dataclass, field +from uuid import UUID, uuid4 + + +@dataclass +class RecurringCashflow: + id: UUID = field(default_factory=uuid4) + start_date: str = "" + end_date: str = "" + frequency: str = "monthly" + amount: float = 0.0 + category: str = "" + + def to_dict(self) -> dict: + return { + "id": str(self.id), + "start_date": self.start_date, + "end_date": self.end_date, + "frequency": self.frequency, + "amount": self.amount, + "category": self.category, + } + + @classmethod + def from_dict(cls, data: dict) -> "RecurringCashflow": + return cls( + id=UUID(data["id"]), + start_date=data.get("start_date", ""), + end_date=data.get("end_date", ""), + frequency=data.get("frequency", "monthly"), + amount=data.get("amount", 0.0), + category=data.get("category", ""), + ) diff --git a/domain/scenario.py b/domain/scenario.py new file mode 100644 index 0000000..349c5ba --- /dev/null +++ b/domain/scenario.py @@ -0,0 +1,33 @@ +from dataclasses import dataclass, field +from uuid import UUID, uuid4 + + +@dataclass +class ForecastScenario: + id: UUID = field(default_factory=uuid4) + name: str = "baseline" + income_multiplier: float = 1.0 + expense_multiplier: float = 1.0 + growth_multiplier: float = 1.0 + description: str = "" + + def to_dict(self) -> dict: + return { + "id": str(self.id), + "name": self.name, + "income_multiplier": self.income_multiplier, + "expense_multiplier": self.expense_multiplier, + "growth_multiplier": self.growth_multiplier, + "description": self.description, + } + + @classmethod + def from_dict(cls, data: dict) -> "ForecastScenario": + return cls( + id=UUID(data["id"]), + name=data["name"], + income_multiplier=data.get("income_multiplier", 1.0), + expense_multiplier=data.get("expense_multiplier", 1.0), + growth_multiplier=data.get("growth_multiplier", 1.0), + description=data.get("description", ""), + ) diff --git a/domain/transaction.py b/domain/transaction.py new file mode 100644 index 0000000..0039556 --- /dev/null +++ b/domain/transaction.py @@ -0,0 +1,33 @@ +from dataclasses import dataclass, field +from uuid import UUID, uuid4 + + +@dataclass +class Transaction: + id: UUID = field(default_factory=uuid4) + date: str = "" + account: str = "" + category: str = "" + amount: float = 0.0 + description: str = "" + + def to_dict(self) -> dict: + return { + "id": str(self.id), + "date": self.date, + "account": self.account, + "category": self.category, + "amount": self.amount, + "description": self.description, + } + + @classmethod + def from_dict(cls, data: dict) -> "Transaction": + return cls( + id=UUID(data["id"]), + date=data["date"], + account=data.get("account", ""), + category=data.get("category", ""), + amount=data.get("amount", 0.0), + description=data.get("description", ""), + ) diff --git a/infrastructure/ai/__init__.py b/infrastructure/ai/__init__.py new file mode 100644 index 0000000..5138fa8 --- /dev/null +++ b/infrastructure/ai/__init__.py @@ -0,0 +1,4 @@ +from infrastructure.ai import prompts +from infrastructure.ai.assistant import AssistantError, AssistantService + +__all__ = ["AssistantService", "AssistantError", "prompts"] diff --git a/infrastructure/ai/assistant.py b/infrastructure/ai/assistant.py new file mode 100644 index 0000000..3515cda --- /dev/null +++ b/infrastructure/ai/assistant.py @@ -0,0 +1,69 @@ +import json + +from infrastructure.ai import prompts +from domain import CurrencyConverter, FinancialModel +from application.forecast import ForecastService + + +class AssistantError(Exception): + pass + + +class AssistantService: + 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) + forecast_result = forecast_service.forecast_cashflow(months) + summary = forecast_service.summary(months) + + prompt = prompts.format_context( + model_json=json.dumps(self.model.to_dict(), indent=2, ensure_ascii=False), + forecast_json=json.dumps(forecast_result, indent=2, ensure_ascii=False), + months=months, + base_currency=self.model.base_currency, + display_currency=self.display_currency, + ) + + return { + "prompt": prompt, + "summary": summary, + "forecast": forecast_result, + "ai_response": None, + } + + def advice(self, question: str, months: int = 12) -> dict: + forecast_service = ForecastService(self.model) + forecast_result = forecast_service.forecast_cashflow(months) + + prompt = prompts.ADVICE_PROMPT.format( + model_json=json.dumps(self.model.to_dict(), indent=2, ensure_ascii=False), + forecast_json=json.dumps(forecast_result, indent=2, ensure_ascii=False), + question=question, + base_currency=self.model.base_currency, + display_currency=self.display_currency, + ) + + return { + "prompt": prompt, + "ai_response": None, + } + + 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, + "ai_response": None, + } diff --git a/infrastructure/ai/prompts.py b/infrastructure/ai/prompts.py new file mode 100644 index 0000000..12c107c --- /dev/null +++ b/infrastructure/ai/prompts.py @@ -0,0 +1,21 @@ +from infrastructure.cli.i18n import t + +ANALYZE_PROMPT = t("prompt.analyze") +ADVICE_PROMPT = t("prompt.advice") +SCENARIO_COMPARISON_PROMPT = t("prompt.scenario_comparison") + + +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/infrastructure/cli/__init__.py b/infrastructure/cli/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/infrastructure/cli/config.py b/infrastructure/cli/config.py new file mode 100644 index 0000000..3d9c8de --- /dev/null +++ b/infrastructure/cli/config.py @@ -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] diff --git a/infrastructure/cli/i18n.py b/infrastructure/cli/i18n.py new file mode 100644 index 0000000..9d7c753 --- /dev/null +++ b/infrastructure/cli/i18n.py @@ -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) diff --git a/infrastructure/cli/main.py b/infrastructure/cli/main.py new file mode 100644 index 0000000..bc4ff13 --- /dev/null +++ b/infrastructure/cli/main.py @@ -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() diff --git a/infrastructure/sync/__init__.py b/infrastructure/sync/__init__.py new file mode 100644 index 0000000..0dc93e3 --- /dev/null +++ b/infrastructure/sync/__init__.py @@ -0,0 +1,3 @@ +from infrastructure.sync.excel_sync import ExcelSync, SyncError + +__all__ = ["ExcelSync", "SyncError"] diff --git a/infrastructure/sync/excel_sync.py b/infrastructure/sync/excel_sync.py new file mode 100644 index 0000000..b7d542c --- /dev/null +++ b/infrastructure/sync/excel_sync.py @@ -0,0 +1,135 @@ +from pathlib import Path +from uuid import UUID + +from openpyxl import Workbook, load_workbook + +from domain import ( + Account, + Asset, + ExchangeRate, + FinancialModel, + Liability, + RecurringCashflow, + Transaction, +) + + +class SyncError(Exception): + pass + + +_SHEET_CONFIG = { + "Accounts": { + "fields": ["id", "name", "currency", "balance"], + "cls": Account, + }, + "Transactions": { + "fields": ["id", "date", "account", "category", "amount", "description"], + "cls": Transaction, + }, + "Recurring": { + "fields": ["id", "start_date", "end_date", "frequency", "amount", "category"], + "cls": RecurringCashflow, + }, + "Assets": { + "fields": ["id", "name", "value", "growth_rate"], + "cls": Asset, + }, + "Liabilities": { + "fields": ["id", "name", "balance", "interest", "payment"], + "cls": Liability, + }, + "ExchangeRates": { + "fields": ["from_currency", "to_currency", "rate"], + "cls": ExchangeRate, + }, +} + + +class ExcelSync: + def import_model(self, path: str | Path) -> FinancialModel: + path = Path(path) + if not path.exists(): + raise SyncError(f"File not found: {path}") + + wb = load_workbook(path, read_only=True, data_only=True) + model = FinancialModel() + model.exchange_rates = [] + + for sheet_name, config in _SHEET_CONFIG.items(): + if sheet_name not in wb.sheetnames: + continue + ws = wb[sheet_name] + rows = list(ws.iter_rows(values_only=True)) + if len(rows) < 2: + continue + + headers = [str(h).strip().lower() if h else "" for h in rows[0]] + for row in rows[1:]: + if not any(v is not None for v in row): + continue + data = {} + for i, header in enumerate(headers): + val = row[i] if i < len(row) else None + if val is not None: + data[header] = str(val) if not isinstance(val, (int, float)) else val + self._add_to_model(model, sheet_name, data) + + 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 + + def export_model(self, model: FinancialModel, path: str | Path) -> None: + path = Path(path) + wb = Workbook() + wb.remove(wb.active) + + collections = { + "Accounts": model.accounts, + "Transactions": model.transactions, + "Recurring": model.recurring, + "Assets": model.assets, + "Liabilities": model.liabilities, + "ExchangeRates": model.exchange_rates, + } + + for sheet_name, items in collections.items(): + config = _SHEET_CONFIG[sheet_name] + ws = wb.create_sheet(title=sheet_name) + ws.append(config["fields"]) + for item in items: + row = [ + str(getattr(item, f)) if isinstance(getattr(item, f), UUID) + else getattr(item, f) + for f in config["fields"] + ] + ws.append(row) + + 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: + try: + if sheet_name == "Accounts": + model.accounts.append(Account.from_dict(data)) + elif sheet_name == "Transactions": + model.transactions.append(Transaction.from_dict(data)) + elif sheet_name == "Recurring": + model.recurring.append(RecurringCashflow.from_dict(data)) + elif sheet_name == "Assets": + model.assets.append(Asset.from_dict(data)) + elif sheet_name == "Liabilities": + model.liabilities.append(Liability.from_dict(data)) + 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/pyproject.toml b/pyproject.toml index 4df6b29..fb257c9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,10 +14,10 @@ dependencies = [ ] [project.scripts] -cf = "cli.main:app" +cf = "infrastructure.cli.main:app" [tool.setuptools.packages.find] -include = ["cashflow_model*", "sync*", "engine*", "ai*", "cli*"] +include = ["domain*", "application*", "infrastructure*"] [tool.ruff] target-version = "py311" diff --git a/tests/conftest.py b/tests/conftest.py index cd8fd7a..4226bbb 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,7 +2,7 @@ from uuid import uuid4 import pytest -from cashflow_model import ( +from domain import ( Account, Asset, CurrencyConverter, diff --git a/tests/test_ai.py b/tests/test_ai.py index 2e3579d..e416191 100644 --- a/tests/test_ai.py +++ b/tests/test_ai.py @@ -1,5 +1,5 @@ -from ai.assistant import AssistantService -from ai.prompts import ADVICE_PROMPT, ANALYZE_PROMPT +from infrastructure.ai.assistant import AssistantService +from infrastructure.ai.prompts import ADVICE_PROMPT, ANALYZE_PROMPT class TestAssistantService: diff --git a/tests/test_cli.py b/tests/test_cli.py index 47cbf7b..db5cb06 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,6 +1,6 @@ from typer.testing import CliRunner -from cli.main import MODEL_PATH, app +from infrastructure.cli.main import MODEL_PATH, app runner = CliRunner() diff --git a/tests/test_currency.py b/tests/test_currency.py index ced7f69..877d0e1 100644 --- a/tests/test_currency.py +++ b/tests/test_currency.py @@ -1,6 +1,6 @@ import pytest -from cashflow_model import CurrencyConverter, CurrencyError, ExchangeRate +from domain import CurrencyConverter, CurrencyError, ExchangeRate class TestExchangeRate: diff --git a/tests/test_excel_sync.py b/tests/test_excel_sync.py index 9cb952c..1b71a7f 100644 --- a/tests/test_excel_sync.py +++ b/tests/test_excel_sync.py @@ -1,7 +1,7 @@ from pathlib import Path -from cashflow_model import FinancialModel -from sync.excel_sync import ExcelSync, SyncError +from domain import FinancialModel +from infrastructure.sync.excel_sync import ExcelSync, SyncError class TestExcelSync: diff --git a/tests/test_forecast.py b/tests/test_forecast.py index bfeba7b..0a50bae 100644 --- a/tests/test_forecast.py +++ b/tests/test_forecast.py @@ -1,6 +1,6 @@ import pytest -from engine.forecast import ForecastError, ForecastService +from application.forecast import ForecastError, ForecastService class TestForecastService: diff --git a/tests/test_i18n.py b/tests/test_i18n.py index 6fcdce8..7fe9137 100644 --- a/tests/test_i18n.py +++ b/tests/test_i18n.py @@ -1,4 +1,4 @@ -from cli.i18n import Translator, get_lang, set_lang, setup_i18n, t +from infrastructure.cli.i18n import Translator, get_lang, set_lang, setup_i18n, t class TestTranslator: diff --git a/tests/test_model.py b/tests/test_model.py index 250d826..8442829 100644 --- a/tests/test_model.py +++ b/tests/test_model.py @@ -1,6 +1,6 @@ from pathlib import Path -from cashflow_model import ( +from domain import ( Account, Asset, ExchangeRate, diff --git a/tests/test_scenarios.py b/tests/test_scenarios.py index e70ab24..99ae9f3 100644 --- a/tests/test_scenarios.py +++ b/tests/test_scenarios.py @@ -1,4 +1,4 @@ -from engine.scenarios import DEFAULT_SCENARIOS, ScenarioService +from application.scenarios import DEFAULT_SCENARIOS, ScenarioService class TestScenarioService: