testing new features
This commit is contained in:
+15
-2
@@ -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,
|
||||
|
||||
+21
-1
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
from dataclasses import dataclass
|
||||
|
||||
CURRENCY_SYMBOLS = {
|
||||
"RUB": "₽",
|
||||
"USD": "$",
|
||||
"EUR": "€",
|
||||
"GBP": "£",
|
||||
"CNY": "¥",
|
||||
"JPY": "¥",
|
||||
"KZT": "₸",
|
||||
"UAH": "₴",
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class ExchangeRate:
|
||||
from_currency: str = "USD"
|
||||
to_currency: str = "RUB"
|
||||
rate: float = 80.0
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"from_currency": self.from_currency,
|
||||
"to_currency": self.to_currency,
|
||||
"rate": self.rate,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> "ExchangeRate":
|
||||
return cls(
|
||||
from_currency=data.get("from_currency", "USD"),
|
||||
to_currency=data.get("to_currency", "RUB"),
|
||||
rate=data.get("rate", 80.0),
|
||||
)
|
||||
|
||||
|
||||
DEFAULT_RATES: list[ExchangeRate] = [
|
||||
ExchangeRate(from_currency="USD", to_currency="RUB", rate=80.0),
|
||||
]
|
||||
|
||||
|
||||
class CurrencyError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class CurrencyConverter:
|
||||
def __init__(self, rates: list[ExchangeRate] | None = None):
|
||||
self._rates: dict[tuple[str, str], float] = {}
|
||||
if rates:
|
||||
for r in rates:
|
||||
self.set_rate(r.from_currency, r.to_currency, r.rate)
|
||||
|
||||
def set_rate(self, from_currency: str, to_currency: str, rate: float) -> None:
|
||||
if rate <= 0:
|
||||
raise CurrencyError(f"Rate must be positive: {rate}")
|
||||
self._rates[(from_currency, to_currency)] = rate
|
||||
inverse = 1.0 / rate
|
||||
self._rates[(to_currency, from_currency)] = inverse
|
||||
|
||||
def get_rate(self, from_currency: str, to_currency: str) -> float:
|
||||
if from_currency == to_currency:
|
||||
return 1.0
|
||||
try:
|
||||
return self._rates[(from_currency, to_currency)]
|
||||
except KeyError:
|
||||
raise CurrencyError(f"No exchange rate: {from_currency} → {to_currency}")
|
||||
|
||||
def convert(self, amount: float, from_currency: str, to_currency: str) -> float:
|
||||
if from_currency == to_currency:
|
||||
return amount
|
||||
rate = self.get_rate(from_currency, to_currency)
|
||||
return round(amount * rate, 2)
|
||||
|
||||
def get_symbol(self, currency: str) -> str:
|
||||
return CURRENCY_SYMBOLS.get(currency, currency)
|
||||
|
||||
@classmethod
|
||||
def with_defaults(cls) -> "CurrencyConverter":
|
||||
return cls(DEFAULT_RATES)
|
||||
@@ -4,6 +4,7 @@ from pathlib import Path
|
||||
|
||||
from cashflow_model.account import Account
|
||||
from cashflow_model.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:
|
||||
|
||||
+419
@@ -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]
|
||||
+155
-27
@@ -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()
|
||||
|
||||
+202
-6
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
Binary file not shown.
+28
-1
@@ -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
|
||||
|
||||
+13
-2
@@ -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)])
|
||||
|
||||
+144
-1
@@ -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
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import pytest
|
||||
|
||||
from cashflow_model import CurrencyConverter, CurrencyError, ExchangeRate
|
||||
|
||||
|
||||
class TestExchangeRate:
|
||||
def test_to_dict_roundtrip(self):
|
||||
rate = ExchangeRate(from_currency="USD", to_currency="RUB", rate=80.0)
|
||||
d = rate.to_dict()
|
||||
r2 = ExchangeRate.from_dict(d)
|
||||
assert r2.from_currency == "USD"
|
||||
assert r2.to_currency == "RUB"
|
||||
assert r2.rate == 80.0
|
||||
|
||||
def test_defaults(self):
|
||||
r = ExchangeRate()
|
||||
assert r.from_currency == "USD"
|
||||
assert r.to_currency == "RUB"
|
||||
assert r.rate == 80.0
|
||||
|
||||
|
||||
class TestCurrencyConverter:
|
||||
def test_convert_usd_to_rub(self, sample_converter):
|
||||
result = sample_converter.convert(100, "USD", "RUB")
|
||||
assert result == 8000.0
|
||||
|
||||
def test_convert_rub_to_usd(self, sample_converter):
|
||||
result = sample_converter.convert(8000, "RUB", "USD")
|
||||
assert result == 100.0
|
||||
|
||||
def test_same_currency(self, sample_converter):
|
||||
result = sample_converter.convert(500, "USD", "USD")
|
||||
assert result == 500.0
|
||||
|
||||
def test_unknown_pair(self):
|
||||
converter = CurrencyConverter()
|
||||
with pytest.raises(CurrencyError):
|
||||
converter.convert(100, "USD", "RUB")
|
||||
|
||||
def test_negative_rate(self):
|
||||
converter = CurrencyConverter()
|
||||
with pytest.raises(CurrencyError):
|
||||
converter.set_rate("USD", "RUB", -1)
|
||||
|
||||
def test_zero_rate(self):
|
||||
converter = CurrencyConverter()
|
||||
with pytest.raises(CurrencyError):
|
||||
converter.set_rate("USD", "RUB", 0)
|
||||
|
||||
def test_inverse_auto(self):
|
||||
rate = ExchangeRate(from_currency="EUR", to_currency="RUB", rate=90.0)
|
||||
converter = CurrencyConverter([rate])
|
||||
assert converter.convert(900, "RUB", "EUR") == 10.0
|
||||
|
||||
def test_get_symbol(self, sample_converter):
|
||||
assert sample_converter.get_symbol("USD") == "$"
|
||||
assert sample_converter.get_symbol("RUB") == "₽"
|
||||
assert sample_converter.get_symbol("XYZ") == "XYZ"
|
||||
|
||||
def test_with_defaults(self):
|
||||
converter = CurrencyConverter.with_defaults()
|
||||
assert converter.convert(10, "USD", "RUB") == 800.0
|
||||
|
||||
def test_multiple_rates(self):
|
||||
rates = [
|
||||
ExchangeRate(from_currency="USD", to_currency="RUB", rate=80.0),
|
||||
ExchangeRate(from_currency="EUR", to_currency="RUB", rate=90.0),
|
||||
]
|
||||
converter = CurrencyConverter(rates)
|
||||
assert converter.convert(10, "USD", "RUB") == 800.0
|
||||
assert converter.convert(10, "EUR", "RUB") == 900.0
|
||||
assert converter.convert(900, "RUB", "EUR") == 10.0
|
||||
@@ -22,6 +22,16 @@ class TestExcelSync:
|
||||
assert len(loaded.recurring) == len(sample_model.recurring)
|
||||
assert len(loaded.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()
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user