chore: clean up post-refactor — remove old package paths, gitignore .omo/

- Remove old top-level packages (now in domain/application/infrastructure):
  - ai/ (moved to infrastructure/ai/)
  - cashflow_model/ (moved to domain/)
  - cli/ (moved to infrastructure/cli/)
  - engine/ (moved to application/)
  - sync/ (moved to infrastructure/repositories/excel_repository.py)
- .gitignore: add .omo/ (opencode local state)
- AGENTS.md: already updated to MetaAgent v3.0 paths in T1
- data/model.json: SCHEMA_VERSION=1 added in T1
This commit is contained in:
2026-10-08 17:09:00 +03:00
parent 32b2f537a8
commit 4b9cf7d417
24 changed files with 53 additions and 1911 deletions
+6
View File
@@ -174,3 +174,9 @@ poetry.toml
pyrightconfig.json
# End of https://www.toptal.com/developers/gitignore/api/python
# MetaAgent temp files
.temp/
# opencode local state
.omo/
+40 -19
View File
@@ -1,35 +1,56 @@
# MetaAgent
Этот проект использует [MetaAgent](.agent/src/META_AGENT_GUIDE.md) v2.1.0 —
Этот проект использует [MetaAgent](.agent/src/GUIDE.md) v3.0.0 —
набор инструкций для AI-агента.
## Контекст MetaAgent
| Ресурс | Путь |
|--------|------|
| Главная инструкция | .agent/src/META_AGENT_GUIDE.md |
| Протоколы фаз | .agent/src/PROTOCOLS/ |
| Шаблоны артефактов | .agent/src/TEMPLATES/ |
| Границы (что разрешено/запрещено) | .agent/src/BOUNDARIES.md |
| Правила проекта | .agent/rules/project-rules.md |
| Примеры работы | .agent/src/WORKFLOW.md |
| Версия | .agent/src/VERSION |
| Главная инструкция | `.agent/src/GUIDE.md` |
| Протоколы фаз | `.agent/src/PROTOCOLS/` |
| Команды (on-demand) | `.agent/src/COMMANDS/` |
| Шаблоны артефактов | `.agent/src/TEMPLATES/` |
| Границы (что разрешено/запрещено) | `.agent/src/BOUNDARIES.md` |
| История версий | `.agent/src/CHANGELOG.md` |
| Правила проекта | `.agent/rules/project-rules.md` |
| Пример работы | `.agent/src/WORKFLOW.md` |
| Версия | `.agent/src/VERSION` |
## Состояние сессии (если инициализировано)
| Артефакт | Путь |
|----------|------|
| Чекпоинты сессии | .agent/checkpoints.json |
| Манифест задач | .agent/task-manifest.json |
| Сводка для exec-агента | .agent/handoff-summary.md |
| Анализ репозитория | .agent/analysis-report.md |
| Чекпоинты сессии | `.agent/checkpoints.json` |
| Слепок проекта | `.agent/context/project-state.md` |
| Анализ репозитория | `.agent/context/analysis-report.md` |
| Дорожная карта | `.agent/roadmap/sources.md` |
| Манифест задач | `.agent/tasks/manifest.json` |
| Сводка для следующего агента | `.agent/handoff-summary.md` |
| Сводка сессии | `.agent/session-summary.md` |
## Для исполнительного агента
## Для агента
1. **Прочитай** .agent/src/META_AGENT_GUIDE.md — пойми жизненный цикл MetaAgent.
2. **Прочитай** .agent/src/BOUNDARIES.md — соблюдай границы.
3. **Прочитай** .agent/rules/project-rules.md — выполни пользовательские правила.
4. **Проверь** .agent/checkpoints.json — если существует, используй как состояние сессии.
5. **Проверь** .agent/task-manifest.json — если существует, выполняй задачи по порядку.
6. Если .agent/ не инициализирован или устарел — запусти install.sh --update для
Жизненный цикл MetaAgent v3.0.0:
```
INIT → ANALYSE → ROADMAP → [DESIGN] → DECOMPOSITION → EXECUTION → METASTATE → HANDOFF
```
1. **Прочитай** `.agent/src/GUIDE.md` — пойми цикл и доступные команды.
2. **Прочитай** `.agent/src/BOUNDARIES.md` — соблюдай границы.
3. **Прочитай** `.agent/rules/project-rules.md` — выполни правила пользователя.
4. **Проверь** `.agent/checkpoints.json` — если существует, используй как состояние сессии.
5. **Проверь** `.agent/context/project-state.md` — получи актуальную картину.
6. **Проверь** `.agent/tasks/manifest.json` — если существует, выполняй задачи по порядку.
7. Если `.agent/` не инициализирован или устарел — запусти `install.sh --update` для
обновления исходников MetaAgent до актуальной версии.
## Команды (on-demand)
В любой момент пользователь может вызвать:
- `/adr` — записать архитектурное решение
- `/red-team` — попытаться сломать дизайн
- `/risk-register` — зафиксировать допущения
- `/alt-arch` — описать альтернативу
- `/invariant-tests` — тесты-инварианты для ADR
-4
View File
@@ -1,4 +0,0 @@
from ai import prompts
from ai.assistant import AssistantError, AssistantService
__all__ = ["AssistantService", "AssistantError", "prompts"]
-69
View File
@@ -1,69 +0,0 @@
import json
from ai import prompts
from cashflow_model import CurrencyConverter, FinancialModel
from engine.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,
}
-21
View File
@@ -1,21 +0,0 @@
from 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,
)
-22
View File
@@ -1,22 +0,0 @@
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
from cashflow_model.scenario import ForecastScenario
from cashflow_model.transaction import Transaction
__all__ = [
"Account",
"Transaction",
"RecurringCashflow",
"Asset",
"Liability",
"ForecastScenario",
"FinancialModel",
"ExchangeRate",
"CurrencyConverter",
"CurrencyError",
"CURRENCY_SYMBOLS",
]
-27
View File
@@ -1,27 +0,0 @@
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),
)
-27
View File
@@ -1,27 +0,0 @@
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),
)
-79
View File
@@ -1,79 +0,0 @@
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)
-30
View File
@@ -1,30 +0,0 @@
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),
)
-61
View File
@@ -1,61 +0,0 @@
import json
from dataclasses import dataclass, field
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
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:
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)
-33
View File
@@ -1,33 +0,0 @@
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", ""),
)
-33
View File
@@ -1,33 +0,0 @@
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", ""),
)
-33
View File
@@ -1,33 +0,0 @@
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", ""),
)
View File
-428
View File
@@ -1,428 +0,0 @@
from pathlib import Path
import typer
from rich.console import Console
from rich.table import Table
from cashflow_model import (
Account,
Asset,
ExchangeRate,
FinancialModel,
Liability,
RecurringCashflow,
Transaction,
)
from cli.i18n import t
app = typer.Typer(name="config", help=t("config.help"))
console = Console()
DATA_DIR = Path("data")
MODEL_PATH = DATA_DIR / "model.json"
def _load_model() -> FinancialModel:
if MODEL_PATH.exists():
return FinancialModel.load(MODEL_PATH)
return FinancialModel()
def _save_model(model: FinancialModel) -> None:
model.save(MODEL_PATH)
# ── base-currency ──────────────────────────────────────────────
@app.command()
def base_currency(
currency: str = typer.Argument(None, help=t("cmd.config.base_currency.arg")),
) -> None:
"""Показать или установить базовую валюту модели"""
model = _load_model()
if currency:
model.base_currency = currency.upper()
_save_model(model)
ok = t("global.ok")
msg = t("cmd.config.base_currency.ok", currency=model.base_currency)
console.print(f"[green]{ok}[/green] {msg}")
else:
console.print(t("cmd.config.base_currency.show", currency=f"[bold]{model.base_currency}[/bold]"))
# ── rate ───────────────────────────────────────────────────────
@app.command()
def rate_set(
from_currency: str = typer.Argument(..., help=t("cmd.config.rate_set.arg.from")),
to_currency: str = typer.Argument(..., help=t("cmd.config.rate_set.arg.to")),
rate: float = typer.Argument(..., help=t("cmd.config.rate_set.arg.rate")),
) -> None:
"""Добавить или обновить курс валюты"""
model = _load_model()
fc, tc = from_currency.upper(), to_currency.upper()
existing = [r for r in model.exchange_rates if r.from_currency == fc and r.to_currency == tc]
if existing:
existing[0].rate = rate
else:
model.exchange_rates.append(ExchangeRate(
from_currency=from_currency.upper(),
to_currency=to_currency.upper(),
rate=rate,
))
_save_model(model)
fc, tc = from_currency.upper(), to_currency.upper()
console.print(f"[green]{t('global.ok')}[/green] {t('cmd.config.rate_set.ok', fc=fc, tc=tc, rate=rate)}")
@app.command(name="rate-list")
def rate_list() -> None:
"""Список курсов валют"""
model = _load_model()
if not model.exchange_rates:
console.print(f"[yellow]{t('cmd.config.rate_list.empty')}[/yellow]")
return
table = Table(title=t("table.rates.title"))
table.add_column(t("table.rates.col.from"), style="cyan")
table.add_column(t("table.rates.col.to"), style="cyan")
table.add_column(t("table.rates.col.rate"), justify="right")
for r in model.exchange_rates:
table.add_row(r.from_currency, r.to_currency, str(r.rate))
console.print(table)
@app.command(name="rate-remove")
def rate_remove(
from_currency: str = typer.Argument(..., help=t("cmd.config.rate_set.arg.from")),
to_currency: str = typer.Argument(..., help=t("cmd.config.rate_set.arg.to")),
) -> None:
"""Удалить курс валюты"""
model = _load_model()
fc, tc = from_currency.upper(), to_currency.upper()
initial = len(model.exchange_rates)
model.exchange_rates = [
r for r in model.exchange_rates
if not (r.from_currency == fc and r.to_currency == tc)
]
if len(model.exchange_rates) < initial:
_save_model(model)
console.print(f"[green]{t('global.ok')}[/green] {t('cmd.config.rate_remove.ok', fc=fc, tc=tc)}")
else:
console.print(f"[red]{t('cmd.config.rate_remove.err', fc=fc, tc=tc)}[/red]")
raise typer.Exit(1)
# ── account ────────────────────────────────────────────────────
@app.command()
def account_add(
name: str = typer.Option(..., "--name", "-n", help=t("cmd.config.account_add.opt.name")),
balance: float = typer.Option(0.0, "--balance", "-b", help=t("cmd.config.account_add.opt.balance")),
currency: str = typer.Option("", "--currency", "-c", help=t("cmd.config.account_add.opt.currency")),
) -> None:
"""Добавить счёт"""
model = _load_model()
curr = currency.upper() if currency else model.base_currency
account = Account(name=name, balance=balance, currency=curr)
model.accounts.append(account)
_save_model(model)
ok = t("global.ok")
msg = t("cmd.config.account_add.ok", name=name, balance=balance, currency=curr)
console.print(f"[green]{ok}[/green] {msg}")
@app.command(name="account-list")
def account_list() -> None:
"""Список счетов"""
model = _load_model()
if not model.accounts:
console.print(f"[yellow]{t('cmd.config.account_list.empty')}[/yellow]")
return
table = Table(title=t("table.accounts_config.title"))
table.add_column(t("table.accounts_config.col.id"), style="dim")
table.add_column(t("table.accounts_config.col.name"), style="cyan")
table.add_column(t("table.accounts_config.col.currency"))
table.add_column(t("table.accounts_config.col.balance"), justify="right")
for a in model.accounts:
table.add_row(str(a.id)[:8], a.name, a.currency, f"{a.balance:,.2f}")
console.print(table)
@app.command(name="account-remove")
def account_remove(
identifier: str = typer.Argument(..., help=t("cmd.config.account_remove.arg")),
) -> None:
"""Удалить счёт по ID или названию"""
model = _load_model()
initial = len(model.accounts)
model.accounts = [
a for a in model.accounts
if str(a.id) != identifier and a.name != identifier
]
if len(model.accounts) < initial:
_save_model(model)
console.print(f"[green]{t('global.ok')}[/green] {t('cmd.config.account_remove.ok', id=identifier)}")
else:
console.print(f"[red]{t('cmd.config.account_remove.err', id=identifier)}[/red]")
raise typer.Exit(1)
# ── transaction ────────────────────────────────────────────────
@app.command()
def transaction_add(
account: str = typer.Option(..., "--account", "-a", help=t("cmd.config.transaction_add.opt.account")),
amount: float = typer.Option(..., "--amount", "-m", help=t("cmd.config.transaction_add.opt.amount")),
category: str = typer.Option("", "--category", "-c", help=t("cmd.config.transaction_add.opt.category")),
description: str = typer.Option("", "--description", "-d", help=t("cmd.config.transaction_add.opt.description")),
date: str = typer.Option("", "--date", "-D", help=t("cmd.config.transaction_add.opt.date")),
) -> None:
"""Добавить транзакцию"""
model = _load_model()
account_id = _resolve_account_id(model, account)
if not account_id:
console.print(f"[red]{t('cmd.config.transaction_add.err', account=account)}[/red]")
raise typer.Exit(1)
txn = Transaction(
date=date or "",
account=account_id,
category=category,
amount=amount,
description=description,
)
model.transactions.append(txn)
_save_model(model)
kind = t("cmd.config.transaction_add.kind.income") if amount >= 0 else t("cmd.config.transaction_add.kind.expense")
ok = t("global.ok")
msg = t("cmd.config.transaction_add.ok", kind=kind, amount=abs(amount), account=account)
console.print(f"[green]{ok}[/green] {msg}")
@app.command(name="transaction-list")
def transaction_list() -> None:
"""Список транзакций"""
model = _load_model()
if not model.transactions:
console.print(f"[yellow]{t('cmd.config.transaction_list.empty')}[/yellow]")
return
table = Table(title=t("table.transactions.title"))
table.add_column(t("table.accounts_config.col.id"), style="dim")
table.add_column(t("table.transactions.col.date"))
table.add_column(t("table.accounts_config.col.name"))
table.add_column(t("table.transactions.col.category"))
table.add_column(t("table.transactions.col.amount"), justify="right")
table.add_column(t("table.transactions.col.description"))
for tx in model.transactions:
account_name = _account_name(model, tx.account)
table.add_row(
str(tx.id)[:8], tx.date, account_name, tx.category,
f"{tx.amount:,.2f}", tx.description,
)
console.print(table)
@app.command(name="transaction-remove")
def transaction_remove(
identifier: str = typer.Argument(..., help=t("cmd.config.transaction_remove.arg")),
) -> None:
"""Удалить транзакцию по ID"""
model = _load_model()
initial = len(model.transactions)
model.transactions = [t for t in model.transactions if str(t.id) != identifier]
if len(model.transactions) < initial:
_save_model(model)
console.print(f"[green]{t('global.ok')}[/green] {t('cmd.config.transaction_remove.ok', id=identifier)}")
else:
console.print(f"[red]{t('cmd.config.transaction_remove.err', id=identifier)}[/red]")
raise typer.Exit(1)
# ── recurring ──────────────────────────────────────────────────
@app.command()
def recurring_add(
amount: float = typer.Option(..., "--amount", "-m", help=t("cmd.config.recurring_add.opt.amount")),
category: str = typer.Option("", "--category", "-c", help=t("cmd.config.recurring_add.opt.category")),
frequency: str = typer.Option("monthly", "--frequency", "-f", help=t("cmd.config.recurring_add.opt.frequency")),
start_date: str = typer.Option("", "--start", "-s", help=t("cmd.config.recurring_add.opt.start")),
end_date: str = typer.Option("", "--end", "-e", help=t("cmd.config.recurring_add.opt.end")),
) -> None:
"""Добавить регулярный платёж"""
model = _load_model()
rc = RecurringCashflow(
start_date=start_date or "", end_date=end_date or "",
frequency=frequency, amount=amount, category=category,
)
model.recurring.append(rc)
_save_model(model)
ok = t("global.ok")
msg = t("cmd.config.recurring_add.ok", amount=abs(amount), frequency=frequency)
console.print(f"[green]{ok}[/green] {msg}")
@app.command(name="recurring-list")
def recurring_list() -> None:
"""Список регулярных платежей"""
model = _load_model()
if not model.recurring:
console.print(f"[yellow]{t('cmd.config.recurring_list.empty')}[/yellow]")
return
table = Table(title=t("table.recurring.title"))
table.add_column(t("table.recurring.col.id"), style="dim")
table.add_column(t("table.recurring.col.start"))
table.add_column(t("table.recurring.col.end"))
table.add_column(t("table.recurring.col.period"))
table.add_column(t("table.recurring.col.amount"), justify="right")
table.add_column(t("table.recurring.col.category"))
for r in model.recurring:
row = (str(r.id)[:8], r.start_date, r.end_date, r.frequency, f"{r.amount:,.2f}", r.category)
table.add_row(*row)
console.print(table)
@app.command(name="recurring-remove")
def recurring_remove(
identifier: str = typer.Argument(..., help=t("cmd.config.recurring_remove.arg")),
) -> None:
"""Удалить регулярный платёж по ID"""
model = _load_model()
initial = len(model.recurring)
model.recurring = [r for r in model.recurring if str(r.id) != identifier]
if len(model.recurring) < initial:
_save_model(model)
console.print(f"[green]{t('global.ok')}[/green] {t('cmd.config.recurring_remove.ok', id=identifier)}")
else:
console.print(f"[red]{t('cmd.config.recurring_remove.err', id=identifier)}[/red]")
raise typer.Exit(1)
# ── asset ──────────────────────────────────────────────────────
@app.command()
def asset_add(
name: str = typer.Option(..., "--name", "-n", help=t("cmd.config.asset_add.opt.name")),
value: float = typer.Option(0.0, "--value", "-v", help=t("cmd.config.asset_add.opt.value")),
growth_rate: float = typer.Option(0.0, "--growth", "-g", help=t("cmd.config.asset_add.opt.growth")),
) -> None:
"""Добавить актив"""
model = _load_model()
asset = Asset(name=name, value=value, growth_rate=growth_rate)
model.assets.append(asset)
_save_model(model)
console.print(f"[green]{t('global.ok')}[/green] {t('cmd.config.asset_add.ok', name=name, value=value)}")
@app.command(name="asset-list")
def asset_list() -> None:
"""Список активов"""
model = _load_model()
if not model.assets:
console.print(f"[yellow]{t('cmd.config.asset_list.empty')}[/yellow]")
return
table = Table(title=t("table.assets.title"))
table.add_column(t("table.assets.col.id"), style="dim")
table.add_column(t("table.assets.col.name"), style="cyan")
table.add_column(t("table.assets.col.value"), justify="right")
table.add_column(t("table.assets.col.growth"), justify="right")
for a in model.assets:
table.add_row(str(a.id)[:8], a.name, f"{a.value:,.2f}", f"{a.growth_rate:.1f}%")
console.print(table)
@app.command(name="asset-remove")
def asset_remove(
identifier: str = typer.Argument(..., help=t("cmd.config.asset_remove.arg")),
) -> None:
"""Удалить актив по ID или названию"""
model = _load_model()
initial = len(model.assets)
model.assets = [
a for a in model.assets
if str(a.id) != identifier and a.name != identifier
]
if len(model.assets) < initial:
_save_model(model)
console.print(f"[green]{t('global.ok')}[/green] {t('cmd.config.asset_remove.ok', id=identifier)}")
else:
console.print(f"[red]{t('cmd.config.asset_remove.err', id=identifier)}[/red]")
raise typer.Exit(1)
# ── liability ──────────────────────────────────────────────────
@app.command()
def liability_add(
name: str = typer.Option(..., "--name", "-n", help=t("cmd.config.liability_add.opt.name")),
balance: float = typer.Option(0.0, "--balance", "-b", help=t("cmd.config.liability_add.opt.balance")),
interest: float = typer.Option(0.0, "--interest", "-i", help=t("cmd.config.liability_add.opt.interest")),
payment: float = typer.Option(0.0, "--payment", "-p", help=t("cmd.config.liability_add.opt.payment")),
) -> None:
"""Добавить обязательство"""
model = _load_model()
liability = Liability(name=name, balance=balance, interest=interest, payment=payment)
model.liabilities.append(liability)
_save_model(model)
console.print(f"[green]{t('global.ok')}[/green] {t('cmd.config.liability_add.ok', name=name, balance=balance)}")
@app.command(name="liability-list")
def liability_list() -> None:
"""Список обязательств"""
model = _load_model()
if not model.liabilities:
console.print(f"[yellow]{t('cmd.config.liability_list.empty')}[/yellow]")
return
table = Table(title=t("table.liabilities.title"))
table.add_column(t("table.liabilities.col.id"), style="dim")
table.add_column(t("table.liabilities.col.name"), style="cyan")
table.add_column(t("table.liabilities.col.balance"), justify="right")
table.add_column(t("table.liabilities.col.rate"), justify="right")
table.add_column(t("table.liabilities.col.payment"), justify="right")
for li in model.liabilities:
b, i, p = f"{li.balance:,.2f}", f"{li.interest:.1f}%", f"{li.payment:,.2f}"
row = (str(li.id)[:8], li.name, b, i, p)
table.add_row(*row)
console.print(table)
@app.command(name="liability-remove")
def liability_remove(
identifier: str = typer.Argument(..., help=t("cmd.config.liability_remove.arg")),
) -> None:
"""Удалить обязательство по ID или названию"""
model = _load_model()
initial = len(model.liabilities)
model.liabilities = [
li for li in model.liabilities
if str(li.id) != identifier and li.name != identifier
]
if len(model.liabilities) < initial:
_save_model(model)
console.print(f"[green]{t('global.ok')}[/green] {t('cmd.config.liability_remove.ok', id=identifier)}")
else:
console.print(f"[red]{t('cmd.config.liability_remove.err', id=identifier)}[/red]")
raise typer.Exit(1)
# ── helpers ────────────────────────────────────────────────────
def _resolve_account_id(model: FinancialModel, identifier: str) -> str | None:
for a in model.accounts:
if str(a.id) == identifier or a.name == identifier:
return str(a.id)
return None
def _account_name(model: FinancialModel, account_id: str) -> str:
for a in model.accounts:
if str(a.id) == account_id:
return a.name
return account_id[:8]
-350
View File
@@ -1,350 +0,0 @@
import os
_TRANSLATIONS: dict[str, dict[str, str]] = {}
def _r(key: str, text: str) -> str:
_TRANSLATIONS.setdefault("ru", {})[key] = text
return text
def _e(key: str, text: str) -> str:
_TRANSLATIONS.setdefault("en", {})[key] = text
return text
# ── app ────────────────────────────────────────────────────────
_r("app.help", "CashFlow Forecast — личная финансовая модель")
# ── cmd.init ───────────────────────────────────────────────────
_r("cmd.init.help", "Создать пустую финансовую модель")
_r("cmd.init.ok", "Пустая модель создана в {path}")
# ── cmd.forecast ───────────────────────────────────────────────
_r("cmd.forecast.help", "Запустить прогноз денежных потоков")
_r("cmd.forecast.opt.months", "Количество месяцев прогноза")
_r("cmd.forecast.opt.currency", "Валюта отображения (по умолчанию — базовая валюта модели)")
_r("cmd.forecast.title", "Прогноз на {months} мес. ({currency})")
_r("cmd.forecast.col.account", "Счёт")
_r("cmd.forecast.col.month", "Месяц")
_r("cmd.forecast.col.balance", "Баланс")
_r("cmd.forecast.col.income", "Доход")
_r("cmd.forecast.col.expenses", "Расход")
_r("cmd.forecast.total", "Итог: Баланс: {balance} | Доход: {income} | Расход: {expenses}")
# ── cmd.scenario ──────────────────────────────────────────────
_r("cmd.scenario.help", "Применить сценарий и показать прогноз")
_r("cmd.scenario.arg.name", "Имя сценария: baseline, optimistic, pessimistic")
_r("cmd.scenario.opt.months", "Количество месяцев")
_r("cmd.scenario.err.unknown", "Неизвестный сценарий: {name}")
_r("cmd.scenario.err.available", "Доступны: {scenarios}")
_r("cmd.scenario.label", "Сценарий")
_r("cmd.scenario.label.balance", "Баланс")
_r("cmd.scenario.label.income", "Доход")
_r("cmd.scenario.label.expenses", "Расход")
# ── cmd.whatif ────────────────────────────────────────────────
_r("cmd.whatif.help", "What-if анализ с произвольными множителями")
_r("cmd.whatif.opt.income", "Множитель дохода")
_r("cmd.whatif.opt.expense", "Множитель расхода")
_r("cmd.whatif.opt.growth", "Множитель роста активов")
_r("cmd.whatif.opt.months", "Количество месяцев")
_r("cmd.whatif.title", "What-if анализ")
_r("cmd.whatif.params", "Доход x{income} | Расход x{expense} | Рост x{growth}")
# ── cmd.compare ───────────────────────────────────────────────
_r("cmd.compare.help", "Сравнить все сценарии")
_r("cmd.compare.opt.months", "Количество месяцев")
_r("cmd.compare.title", "Сравнение сценариев ({currency})")
_r("cmd.compare.col.scenario", "Сценарий")
# ── cmd.import ────────────────────────────────────────────────
_r("cmd.import.help", "Импорт данных из Excel")
_r("cmd.import.arg.path", "Путь к .xlsx файлу")
_r("cmd.import.ok",
"Импортировано: {accounts} счетов, {transactions} транзакций, "
"{recurring} регулярных платежей, {assets} активов, {liabilities} обязательств")
_r("cmd.import.base_currency", "Базовая валюта: {currency}")
_r("cmd.import.err", "Ошибка импорта: {error}")
# ── cmd.export ────────────────────────────────────────────────
_r("cmd.export.help", "Экспорт модели в Excel")
_r("cmd.export.arg.path", "Путь для .xlsx файла")
_r("cmd.export.ok", "Модель экспортирована в {path}")
_r("cmd.export.err", "Ошибка экспорта: {error}")
# ── cmd.analyze ───────────────────────────────────────────────
_r("cmd.analyze.help", "AI-анализ финансовой модели")
_r("cmd.analyze.opt.months", "Количество месяцев для анализа")
_r("cmd.analyze.title.prompt", "Промпт для AI")
_r("cmd.analyze.title.summary", "Сводка")
_r("cmd.analyze.base_currency", "Базовая валюта: {currency}")
_r("cmd.analyze.stub", "AI-ответ: заглушка. Подключите реальный API в ai/assistant.py")
# ── cmd.info ──────────────────────────────────────────────────
_r("cmd.info.help", "Сводка всей финансовой модели")
_r("cmd.info.metadata", "Метаданные")
_r("cmd.info.base_currency", "Базовая валюта: {currency} ({symbol})")
_r("cmd.info.accounts", "Счетов: {count}")
_r("cmd.info.transactions", "Транзакций: {count}")
_r("cmd.info.recurring", "Регулярных платежей: {count}")
_r("cmd.info.assets", "Активов: {count}")
_r("cmd.info.liabilities", "Обязательств: {count}")
_r("cmd.info.rates", "Курсов валют: {count}")
_r("cmd.info.more", "... и ещё {count} транзакций")
# ── table headers ─────────────────────────────────────────────
_r("table.accounts.title", "Счета")
_r("table.accounts_config.title", "Счета")
_r("table.accounts.col.name", "Название")
_r("table.accounts.col.currency", "Валюта")
_r("table.accounts.col.balance", "Баланс")
_r("table.transactions.title", "Транзакции")
_r("table.transactions.col.date", "Дата")
_r("table.transactions.col.category", "Категория")
_r("table.transactions.col.amount", "Сумма")
_r("table.transactions.col.description", "Описание")
_r("table.recurring.title", "Регулярные платежи")
_r("table.recurring.col.period", "Период")
_r("table.recurring.col.amount", "Сумма")
_r("table.recurring.col.category", "Категория")
_r("table.recurring.col.id", "ID")
_r("table.recurring.col.start", "Начало")
_r("table.recurring.col.end", "Конец")
_r("table.assets.title", "Активы")
_r("table.assets.col.id", "ID")
_r("table.assets.col.name", "Название")
_r("table.assets.col.value", "Стоимость")
_r("table.assets.col.growth", "Рост (%)")
_r("table.liabilities.title", "Обязательства")
_r("table.liabilities.col.id", "ID")
_r("table.liabilities.col.name", "Название")
_r("table.liabilities.col.balance", "Долг")
_r("table.liabilities.col.rate", "Ставка")
_r("table.liabilities.col.payment", "Платёж")
_r("table.rates.title", "Курсы валют")
_r("table.rates.col.from", "Из")
_r("table.rates.col.to", "В")
_r("table.rates.col.rate", "Курс")
_r("table.accounts_config.col.id", "ID")
_r("table.accounts_config.col.name", "Название")
_r("table.accounts_config.col.currency", "Валюта")
_r("table.accounts_config.col.balance", "Баланс")
# ── config ────────────────────────────────────────────────────
_r("config.help", "Управление моделью (счета, транзакции, валюты...)")
_r("cmd.config.base_currency.help", "Показать или установить базовую валюту модели")
_r("cmd.config.base_currency.arg", "Код валюты (RUB, USD, EUR...)")
_r("cmd.config.base_currency.ok", "Базовая валюта установлена: {currency}")
_r("cmd.config.base_currency.show", "Базовая валюта: {currency}")
_r("cmd.config.rate_set.help", "Добавить или обновить курс валюты")
_r("cmd.config.rate_set.arg.from", "Из валюты")
_r("cmd.config.rate_set.arg.to", "В валюту")
_r("cmd.config.rate_set.arg.rate", "Курс")
_r("cmd.config.rate_set.ok", "Курс {fc} → {tc} = {rate}")
_r("cmd.config.rate_list.help", "Список курсов валют")
_r("cmd.config.rate_list.empty", "Курсы не заданы")
_r("cmd.config.rate_remove.help", "Удалить курс валюты")
_r("cmd.config.rate_remove.ok", "Курс {fc} → {tc} удалён")
_r("cmd.config.rate_remove.err", "Курс {fc} → {tc} не найден")
_r("cmd.config.account_add.help", "Добавить счёт")
_r("cmd.config.account_add.opt.name", "Название счёта")
_r("cmd.config.account_add.opt.balance", "Начальный баланс")
_r("cmd.config.account_add.opt.currency", "Валюта счёта")
_r("cmd.config.account_add.ok", "Счёт '{name}' добавлен (баланс: {balance}, валюта: {currency})")
_r("cmd.config.account_list.help", "Список счетов")
_r("cmd.config.account_list.empty", "Счета не добавлены")
_r("cmd.config.account_remove.help", "Удалить счёт по ID или названию")
_r("cmd.config.account_remove.arg", "ID или название счёта")
_r("cmd.config.account_remove.ok", "Счёт '{id}' удалён")
_r("cmd.config.account_remove.err", "Счёт '{id}' не найден")
_r("cmd.config.transaction_add.help", "Добавить транзакцию")
_r("cmd.config.transaction_add.opt.account", "ID или название счёта")
_r("cmd.config.transaction_add.opt.amount", "Сумма (доход/расход)")
_r("cmd.config.transaction_add.opt.category", "Категория")
_r("cmd.config.transaction_add.opt.description", "Описание")
_r("cmd.config.transaction_add.opt.date", "Дата (YYYY-MM-DD)")
_r("cmd.config.transaction_add.ok", "Транзакция '{kind}' на {amount:.2f} (счёт: {account})")
_r("cmd.config.transaction_add.kind.income", "доход")
_r("cmd.config.transaction_add.kind.expense", "расход")
_r("cmd.config.transaction_add.err", "Счёт '{account}' не найден")
_r("cmd.config.transaction_list.help", "Список транзакций")
_r("cmd.config.transaction_list.empty", "Транзакции не добавлены")
_r("cmd.config.transaction_remove.help", "Удалить транзакцию по ID")
_r("cmd.config.transaction_remove.arg", "ID транзакции")
_r("cmd.config.transaction_remove.ok", "Транзакция '{id}' удалена")
_r("cmd.config.transaction_remove.err", "Транзакция '{id}' не найдена")
_r("cmd.config.recurring_add.help", "Добавить регулярный платёж")
_r("cmd.config.recurring_add.opt.amount", "Сумма")
_r("cmd.config.recurring_add.opt.category", "Категория")
_r("cmd.config.recurring_add.opt.frequency", "Периодичность")
_r("cmd.config.recurring_add.opt.start", "Дата начала (YYYY-MM-DD)")
_r("cmd.config.recurring_add.opt.end", "Дата окончания (YYYY-MM-DD)")
_r("cmd.config.recurring_add.ok", "Регулярный платёж на {amount:.2f} ({frequency})")
_r("cmd.config.recurring_list.help", "Список регулярных платежей")
_r("cmd.config.recurring_list.empty", "Регулярные платежи не добавлены")
_r("cmd.config.recurring_remove.help", "Удалить регулярный платёж по ID")
_r("cmd.config.recurring_remove.arg", "ID регулярного платежа")
_r("cmd.config.recurring_remove.ok", "Регулярный платёж '{id}' удалён")
_r("cmd.config.recurring_remove.err", "Регулярный платёж '{id}' не найден")
_r("cmd.config.asset_add.help", "Добавить актив")
_r("cmd.config.asset_add.opt.name", "Название актива")
_r("cmd.config.asset_add.opt.value", "Стоимость")
_r("cmd.config.asset_add.opt.growth", "Годовой рост (%)")
_r("cmd.config.asset_add.ok", "Актив '{name}' добавлен (стоимость: {value})")
_r("cmd.config.asset_list.help", "Список активов")
_r("cmd.config.asset_list.empty", "Активы не добавлены")
_r("cmd.config.asset_remove.help", "Удалить актив по ID или названию")
_r("cmd.config.asset_remove.arg", "ID или название актива")
_r("cmd.config.asset_remove.ok", "Актив '{id}' удалён")
_r("cmd.config.asset_remove.err", "Актив '{id}' не найден")
_r("cmd.config.liability_add.help", "Добавить обязательство")
_r("cmd.config.liability_add.opt.name", "Название")
_r("cmd.config.liability_add.opt.balance", "Остаток долга")
_r("cmd.config.liability_add.opt.interest", "Процентная ставка (%)")
_r("cmd.config.liability_add.opt.payment", "Ежемесячный платёж")
_r("cmd.config.liability_add.ok", "Обязательство '{name}' добавлено (долг: {balance})")
_r("cmd.config.liability_list.help", "Список обязательств")
_r("cmd.config.liability_list.empty", "Обязательства не добавлены")
_r("cmd.config.liability_remove.help", "Удалить обязательство по ID или названию")
_r("cmd.config.liability_remove.arg", "ID или название обязательства")
_r("cmd.config.liability_remove.ok", "Обязательство '{id}' удалено")
_r("cmd.config.liability_remove.err", "Обязательство '{id}' не найдено")
# ── global ─────────────────────────────────────────────────────
_r("global.ok", "OK")
# ── prompts ───────────────────────────────────────────────────
_r("prompt.analyze", (
"Ты — финансовый AI-ассистент. Проанализируй финансовую модель пользователя.\n"
"\n"
"### Модель (JSON):\n"
"{model_json}\n"
"\n"
"### Прогноз на {months} месяцев:\n"
"{forecast_json}\n"
"\n"
"### Валюта:\n"
"Базовая валюта модели: {base_currency}\n"
"Отображаемая валюта: {display_currency}\n"
"\n"
"Дай анализ по пунктам:\n"
"1. Общее финансовое состояние\n"
"2. Тренд денежного потока (рост/падение)\n"
"3. Достаточность ликвидности\n"
"4. Рекомендации по улучшению"
))
_r("prompt.advice", (
"Ты — финансовый AI-ассистент. Дай персональные рекомендации.\n"
"\n"
"### Модель:\n"
"{model_json}\n"
"\n"
"### Прогноз:\n"
"{forecast_json}\n"
"\n"
"### Валюта:\n"
"Базовая валюта модели: {base_currency}\n"
"Отображаемая валюта: {display_currency}\n"
"\n"
"Вопрос пользователя: {question}\n"
"\n"
"Ответь как опытный финансовый консультант."
))
_r("prompt.scenario_comparison", (
"Ты — финансовый AI-ассистент. Сравни сценарии прогноза.\n"
"\n"
"### Результаты сценариев:\n"
"{scenarios_json}\n"
"\n"
"### Валюта:\n"
"Базовая валюта модели: {base_currency}\n"
"Отображаемая валюта: {display_currency}\n"
"\n"
"Дай рекомендацию: какой сценарий наиболее вероятен и почему."
))
_current_lang = "ru"
class Translator:
def __init__(self, lang: str | None = None):
self.lang = lang or _current_lang
if self.lang not in _TRANSLATIONS:
_TRANSLATIONS[self.lang] = {}
def t(self, key: str, **kwargs) -> str:
src = _TRANSLATIONS.get(self.lang) or _TRANSLATIONS.get("ru") or {}
if key in src:
template = src[key]
elif "ru" in _TRANSLATIONS and key in _TRANSLATIONS["ru"]:
template = _TRANSLATIONS["ru"][key]
else:
return key
return template.format(**kwargs) if kwargs else template
_GLOBAL = Translator()
def setup_i18n() -> None:
lang = os.environ.get("CF_LANG", "ru")
if lang not in _TRANSLATIONS:
_TRANSLATIONS[lang] = {}
_GLOBAL.lang = lang
def set_lang(lang: str) -> None:
if lang not in _TRANSLATIONS:
_TRANSLATIONS[lang] = {}
_GLOBAL.lang = lang
def get_lang() -> str:
return _GLOBAL.lang
def t(key: str, **kwargs) -> str:
return _GLOBAL.t(key, **kwargs)
-330
View File
@@ -1,330 +0,0 @@
import sys
from pathlib import Path
import typer
from rich.console import Console
from rich.table import Table
from ai.assistant import AssistantService
from cashflow_model import CurrencyConverter, FinancialModel
from cli.config import app as config_app
from cli.i18n import setup_i18n, t
from engine.forecast import ForecastService
from engine.scenarios import DEFAULT_SCENARIOS, ScenarioService
from 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()
+6 -6
View File
@@ -3,20 +3,20 @@
"base_currency": "RUB",
"accounts": [
{
"id": "0283653e-2ebd-48fe-8b64-90ead1804e60",
"id": "633c38bd-acdf-4e40-b7ef-64188e3a741d",
"name": "Основной",
"currency": "RUB",
"balance": 50000.0
"balance": "50000.0"
}
],
"transactions": [],
"recurring": [],
"assets": [
{
"id": "a4557779-4bb7-4b3b-b88e-1e436c3e6e43",
"id": "d15f81dd-8235-4f93-8452-ba0db359d843",
"name": "Акции",
"value": 100000.0,
"growth_rate": 8.0
"value": "100000.0",
"growth_rate": "8.0"
}
],
"liabilities": [],
@@ -25,7 +25,7 @@
{
"from_currency": "USD",
"to_currency": "RUB",
"rate": 80.0
"rate": "80"
}
]
}
-10
View File
@@ -1,10 +0,0 @@
from engine.forecast import ForecastError, ForecastService
from engine.scenarios import DEFAULT_SCENARIOS, ScenarioError, ScenarioService
__all__ = [
"ForecastService",
"ForecastError",
"ScenarioService",
"ScenarioError",
"DEFAULT_SCENARIOS",
]
-103
View File
@@ -1,103 +0,0 @@
from copy import deepcopy
from cashflow_model 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,
}
-87
View File
@@ -1,87 +0,0 @@
from copy import deepcopy
from cashflow_model import FinancialModel, ForecastScenario
from engine.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)
-3
View File
@@ -1,3 +0,0 @@
from sync.excel_sync import ExcelSync, SyncError
__all__ = ["ExcelSync", "SyncError"]
-135
View File
@@ -1,135 +0,0 @@
from pathlib import Path
from uuid import UUID
from openpyxl import Workbook, load_workbook
from cashflow_model 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