aoiro 0.0.3__py3-none-any.whl → 0.1.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
aoiro/__init__.py CHANGED
@@ -1 +1,52 @@
1
- __version__ = "0.0.3"
1
+ __version__ = "0.1.1"
2
+ from ._ledger import (
3
+ GeneralLedgerLine,
4
+ GeneralLedgerLineImpl,
5
+ LedgerElement,
6
+ LedgerElementImpl,
7
+ LedgerLine,
8
+ LedgerLineImpl,
9
+ MultiLedgerLine,
10
+ MultiLedgerLineImpl,
11
+ generalledger_line_to_multiledger_line,
12
+ generalledger_to_multiledger,
13
+ multiledger_line_to_generalledger_line,
14
+ multiledger_line_to_ledger_line,
15
+ multiledger_to_generalledger,
16
+ multiledger_to_ledger,
17
+ )
18
+ from ._multidimensional import multidimensional_ledger_to_ledger
19
+ from ._sheets import get_sheets
20
+ from .reader import (
21
+ ledger_from_expenses,
22
+ ledger_from_sales,
23
+ read_all_csvs,
24
+ read_general_ledger,
25
+ read_simple_csvs,
26
+ withholding_tax,
27
+ )
28
+
29
+ __all__ = [
30
+ "GeneralLedgerLine",
31
+ "GeneralLedgerLineImpl",
32
+ "LedgerElement",
33
+ "LedgerElementImpl",
34
+ "LedgerLine",
35
+ "LedgerLineImpl",
36
+ "MultiLedgerLine",
37
+ "MultiLedgerLineImpl",
38
+ "generalledger_line_to_multiledger_line",
39
+ "generalledger_to_multiledger",
40
+ "get_sheets",
41
+ "ledger_from_expenses",
42
+ "ledger_from_sales",
43
+ "multidimensional_ledger_to_ledger",
44
+ "multiledger_line_to_generalledger_line",
45
+ "multiledger_line_to_ledger_line",
46
+ "multiledger_to_generalledger",
47
+ "multiledger_to_ledger",
48
+ "read_all_csvs",
49
+ "read_general_ledger",
50
+ "read_simple_csvs",
51
+ "withholding_tax",
52
+ ]
aoiro/__main__.py CHANGED
@@ -2,4 +2,4 @@
2
2
 
3
3
  from .cli import app
4
4
 
5
- app(prog_name="aoiro")
5
+ app()
aoiro/_ledger.py ADDED
@@ -0,0 +1,298 @@
1
+ from collections.abc import Sequence
2
+ from decimal import Decimal
3
+ from itertools import chain
4
+ from typing import Any, Callable, Literal, Protocol, TypeVar
5
+
6
+ import attrs
7
+ import pandas as pd
8
+ from account_codes_jp._common import SUNDRY
9
+
10
+ AccountSundry = Literal["諸口"]
11
+ Account = TypeVar("Account", bound=str)
12
+ Currency = TypeVar("Currency", bound=str)
13
+
14
+
15
+ class _LedgerLineBase(Protocol):
16
+ date: pd.Timestamp
17
+ """The date when the transaction occurred."""
18
+
19
+
20
+ class LedgerLine(_LedgerLineBase, Protocol[Account, Currency]):
21
+ amount: Decimal
22
+ """The amount. Must be non-negative."""
23
+ currency: Currency
24
+ """The currency."""
25
+ debit_account: Account
26
+ """The account written on the debit side."""
27
+ credit_account: Account
28
+ """The account written on the credit side."""
29
+
30
+
31
+ class LedgerElement(Protocol[Account, Currency]):
32
+ account: Account
33
+ """The account."""
34
+ amount: Decimal
35
+ """The amount."""
36
+ currency: Currency
37
+ """The currency."""
38
+
39
+
40
+ class MultiLedgerLine(_LedgerLineBase, Protocol[Account, Currency]):
41
+ debit: Sequence[LedgerElement[Account, Currency]]
42
+ """The accounts and amounts on the debit side.
43
+ Each amount needs to be non-negative."""
44
+ credit: Sequence[LedgerElement[Account, Currency]]
45
+ """The accounts and amounts on the credit side.
46
+ Each amount needs to be non-negative."""
47
+
48
+
49
+ class GeneralLedgerLine(_LedgerLineBase, Protocol[Account, Currency]):
50
+ values: Sequence[LedgerElement[Account, Currency]]
51
+ """The accounts and amounts. Amounts does not need to be non-negative."""
52
+
53
+
54
+ @attrs.frozen(kw_only=True)
55
+ class LedgerLineImpl(LedgerLine[Account, Currency]):
56
+ date: pd.Timestamp
57
+ amount: Decimal = attrs.field()
58
+ currency: Currency
59
+ debit_account: Account
60
+ credit_account: Account
61
+
62
+ def __repr__(self) -> str:
63
+ return (
64
+ f"{self.date} {self.amount} {self.currency} "
65
+ f"{self.debit_account} / {self.credit_account}"
66
+ )
67
+
68
+ @amount.validator
69
+ def _validate_amount(self, attribute: Any, value: Decimal) -> None:
70
+ if value < Decimal(0):
71
+ raise ValueError("amount must be non-negative")
72
+
73
+
74
+ @attrs.frozen(kw_only=True)
75
+ class LedgerElementImpl(LedgerElement[Account, Currency]):
76
+ account: Account
77
+ amount: Decimal
78
+ currency: Currency
79
+
80
+ def __repr__(self) -> str:
81
+ return f"{self.account} {self.amount} {self.currency}"
82
+
83
+
84
+ @attrs.frozen(kw_only=True, auto_detect=True)
85
+ class MultiLedgerLineImpl(MultiLedgerLine[Account, Currency]):
86
+ date: pd.Timestamp
87
+ debit: Sequence[LedgerElement[Account, Currency]] = attrs.field()
88
+ credit: Sequence[LedgerElement[Account, Currency]] = attrs.field()
89
+
90
+ def __repr__(self) -> str:
91
+ date = pd.Series([self.date], name="date")
92
+ debit = pd.DataFrame(
93
+ self.debit, columns=["debit_account", "amount", "currency"]
94
+ )
95
+ credit = pd.DataFrame(
96
+ self.credit, columns=["credit_account", "amount", "currency"]
97
+ )
98
+ return (
99
+ pd.concat([date, debit, credit], axis=1)
100
+ .replace({pd.NaT: ""})
101
+ .fillna("")
102
+ .to_string(index=False, header=False)
103
+ )
104
+
105
+ @debit.validator
106
+ def _validate_debit(
107
+ self, attribute: Any, value: Sequence[LedgerElement[Account, Currency]]
108
+ ) -> None:
109
+ if any(el.amount < Decimal(0) for el in value):
110
+ raise ValueError("amount must be non-negative")
111
+
112
+ @credit.validator
113
+ def _validate_credit(
114
+ self, attribute: Any, value: Sequence[LedgerElement[Account, Currency]]
115
+ ) -> None:
116
+ if any(el.amount < Decimal(0) for el in value):
117
+ raise ValueError("amount must be non-negative")
118
+
119
+
120
+ @attrs.frozen(kw_only=True, auto_detect=True)
121
+ class GeneralLedgerLineImpl(GeneralLedgerLine[Account, Currency]):
122
+ date: pd.Timestamp
123
+ values: Sequence[LedgerElement[Account, Currency]]
124
+
125
+
126
+ def generalledger_line_to_multiledger_line(
127
+ line: GeneralLedgerLine[Account, Currency], is_debit: Callable[[Account], bool], /
128
+ ) -> MultiLedgerLine[Account, Currency]:
129
+ """
130
+ Convert a GeneralLedgerLine to a MultiLedgerLine.
131
+
132
+ Parameters
133
+ ----------
134
+ line : GeneralLedgerLine[Account, Currency]
135
+ The GeneralLedgerLine to convert.
136
+ is_debit : Callable[[Account], bool]
137
+ Whether the account is a debit account.
138
+
139
+ Returns
140
+ -------
141
+ MultiLedgerLine[Account, Currency]
142
+ The converted MultiLedgerLine.
143
+
144
+ """
145
+ debit = []
146
+ credit = []
147
+ for el in line.values:
148
+ if is_debit(el.account) == (el.amount > 0):
149
+ debit.append(
150
+ LedgerElementImpl(
151
+ account=el.account, amount=abs(el.amount), currency=el.currency
152
+ )
153
+ )
154
+ else:
155
+ credit.append(
156
+ LedgerElementImpl(
157
+ account=el.account, amount=abs(el.amount), currency=el.currency
158
+ )
159
+ )
160
+ return MultiLedgerLineImpl(date=line.date, debit=debit, credit=credit)
161
+
162
+
163
+ def multiledger_line_to_generalledger_line(
164
+ line: MultiLedgerLine[Account, Currency], /
165
+ ) -> GeneralLedgerLine[Account, Currency]:
166
+ """
167
+ Convert a MultiLedgerLine to a GeneralLedgerLine.
168
+
169
+ Parameters
170
+ ----------
171
+ line : MultiLedgerLine[Account, Currency]
172
+ The MultiLedgerLine to convert.
173
+
174
+ Returns
175
+ -------
176
+ GeneralLedgerLine[Account, Currency]
177
+ The converted GeneralLedgerLine.
178
+
179
+ """
180
+ return GeneralLedgerLineImpl(
181
+ date=line.date,
182
+ values=[*line.debit, *line.credit],
183
+ )
184
+
185
+
186
+ def multiledger_line_to_ledger_line(
187
+ line: MultiLedgerLine[Account, Currency], /
188
+ ) -> Sequence[LedgerLine[Account | AccountSundry, Currency]]:
189
+ """
190
+ Convert a MultiLedgerLine to a list of LedgerLine.
191
+
192
+ Parameters
193
+ ----------
194
+ line : MultiLedgerLine[Account, Currency]
195
+ The MultiLedgerLine to convert.
196
+
197
+ Returns
198
+ -------
199
+ Sequence[LedgerLine[Account | AccountSundry, Currency]]
200
+ The converted LedgerLines.
201
+
202
+ """
203
+ if (
204
+ len(line.debit) == len(line.credit) == 1
205
+ and line.debit[0].amount == line.credit[0].amount
206
+ and line.debit[0].currency == line.credit[0].currency
207
+ ):
208
+ return [
209
+ LedgerLineImpl(
210
+ date=line.date,
211
+ amount=line.debit[0].amount,
212
+ currency=line.debit[0].currency,
213
+ debit_account=line.debit[0].account,
214
+ credit_account=line.credit[0].account,
215
+ )
216
+ ]
217
+ return [
218
+ LedgerLineImpl(
219
+ date=line.date,
220
+ amount=el.amount,
221
+ currency=el.currency,
222
+ debit_account=el.account,
223
+ credit_account=SUNDRY,
224
+ )
225
+ for el in line.debit
226
+ ] + [
227
+ LedgerLineImpl(
228
+ date=line.date,
229
+ amount=el.amount,
230
+ currency=el.currency,
231
+ debit_account=SUNDRY,
232
+ credit_account=el.account,
233
+ )
234
+ for el in line.credit
235
+ ]
236
+
237
+
238
+ def generalledger_to_multiledger(
239
+ lines: Sequence[GeneralLedgerLine[Account, Currency]],
240
+ is_debit: Callable[[Account], bool],
241
+ ) -> Sequence[MultiLedgerLine[Account, Currency]]:
242
+ """
243
+ Convert a GeneralLedger to a MultiLedger.
244
+
245
+ Parameters
246
+ ----------
247
+ lines : Sequence[GeneralLedgerLine[Account, Currency]]
248
+ The GeneralLedger to convert.
249
+ is_debit : Callable[[Account], bool]
250
+ Whether the account is a debit account.
251
+
252
+ Returns
253
+ -------
254
+ Sequence[MultiLedgerLine[Account, Currency]]
255
+ The converted MultiLedger.
256
+
257
+ """
258
+ return [generalledger_line_to_multiledger_line(line, is_debit) for line in lines]
259
+
260
+
261
+ def multiledger_to_generalledger(
262
+ lines: Sequence[MultiLedgerLine[Account, Currency]],
263
+ ) -> Sequence[GeneralLedgerLine[Account, Currency]]:
264
+ """
265
+ Convert a MultiLedger to a GeneralLedger.
266
+
267
+ Parameters
268
+ ----------
269
+ lines : Sequence[MultiLedgerLine[Account, Currency]]
270
+ The MultiLedger to convert.
271
+
272
+ Returns
273
+ -------
274
+ Sequence[GeneralLedgerLine[Account, Currency]]
275
+ The converted GeneralLedger.
276
+
277
+ """
278
+ return [multiledger_line_to_generalledger_line(line) for line in lines]
279
+
280
+
281
+ def multiledger_to_ledger(
282
+ lines: Sequence[MultiLedgerLine[Account, Currency]],
283
+ ) -> Sequence[LedgerLine[Account | AccountSundry, Currency]]:
284
+ """
285
+ Convert a MultiLedger to a Ledger.
286
+
287
+ Parameters
288
+ ----------
289
+ lines : Sequence[MultiLedgerLine[Account, Currency]]
290
+ The MultiLedger to convert.
291
+
292
+ Returns
293
+ -------
294
+ Sequence[LedgerLine[Account | AccountSundry, Currency]]
295
+ The converted Ledger.
296
+
297
+ """
298
+ return list(chain(*[multiledger_line_to_ledger_line(line) for line in lines]))
@@ -0,0 +1,186 @@
1
+ from collections import defaultdict
2
+ from collections.abc import Iterable, Mapping, Sequence
3
+ from decimal import ROUND_DOWN, Decimal, localcontext
4
+ from pathlib import Path
5
+ from sys import platform
6
+ from typing import Callable, Literal
7
+
8
+ import numpy as np
9
+ import pandas as pd
10
+ import requests
11
+
12
+ from ._ledger import (
13
+ Account,
14
+ Currency,
15
+ GeneralLedgerLine,
16
+ GeneralLedgerLineImpl,
17
+ LedgerElement,
18
+ LedgerElementImpl,
19
+ )
20
+
21
+
22
+ def _get_currency(
23
+ lines: Sequence[GeneralLedgerLine[Account, Currency]],
24
+ ) -> Sequence[Currency]:
25
+ return np.unique([x.currency for line in lines for x in line.values])
26
+
27
+
28
+ def get_prices(
29
+ currency: Iterable[Currency],
30
+ ) -> Mapping[Currency, "pd.Series[Decimal]"]:
31
+ """
32
+ Get prices of the currency indexed by date (not datetime).
33
+
34
+ Interpolates missing dates by forward fill.
35
+
36
+ Parameters
37
+ ----------
38
+ currency : Iterable[Currency]
39
+ The currency to get prices.
40
+
41
+ Returns
42
+ -------
43
+ Mapping[Currency, pd.Series[Decimal]]
44
+ The prices, indexed by date.
45
+
46
+ """
47
+ URL = "https://www.mizuhobank.co.jp/market/quote.csv"
48
+ path_dir = Path("~/.cache/aoiro").expanduser()
49
+ path_dir.mkdir(exist_ok=True, parents=True)
50
+ path = path_dir / "quote.csv"
51
+ if not path.exists():
52
+ with path.open("w", encoding="utf-8") as f:
53
+ r = requests.get(URL, timeout=5)
54
+ r.encoding = "Shift_JIS"
55
+ f.write(r.text)
56
+ df = pd.read_csv(
57
+ path,
58
+ index_col=0,
59
+ skiprows=3 if platform == "win32" else 2,
60
+ na_values=["*****"],
61
+ parse_dates=True,
62
+ )
63
+ # fill missing dates
64
+ df = df.reindex(pd.date_range(df.index[0], df.index[-1]), method="ffill")
65
+ return df
66
+
67
+
68
+ def multidimensional_ledger_to_ledger(
69
+ lines: Sequence[GeneralLedgerLine[Account, Currency]],
70
+ is_debit: Callable[[Account], bool],
71
+ prices: Mapping[Currency, "pd.Series[Decimal]"] = {},
72
+ ) -> Sequence[
73
+ GeneralLedgerLineImpl[Account | Literal["為替差益", "為替差損"], Literal[""]]
74
+ ]:
75
+ """
76
+ Convert multidimensional ledger to ledger.
77
+
78
+ The multidimensional ledger's any account must have
79
+ positive amount.
80
+ At the BOY, the previous B/S sheet
81
+ must be added as a general ledger line, for example,
82
+
83
+ >>> lines.append(
84
+ >>> GeneralLedgerLineImpl(
85
+ >>> date=datetime(2024, 1, 1),
86
+ >>> values=[
87
+ >>> ("売掛金", 1000, ""),
88
+ >>> ("元入金", 1000, "")
89
+ >>> ]
90
+ >>> )
91
+ >>> )
92
+
93
+ Returns
94
+ -------
95
+ GeneralLedgerLineImpl[Account | Literal["為替差益", "為替差損"], Literal[""]]
96
+ The ledger lines.
97
+
98
+ """
99
+ # get prices, use prices_
100
+ lines = sorted(lines, key=lambda x: x.date)
101
+ prices_ = dict(prices)
102
+ del prices
103
+ prices_.update(get_prices(set(_get_currency(lines)) - set(prices_.keys())))
104
+
105
+ # balance of (account, currency) represented by tuple (price, amount)
106
+ balance: dict[Account, dict[Currency, list[tuple[Decimal, Decimal]]]] = defaultdict(
107
+ lambda: defaultdict(list)
108
+ )
109
+
110
+ # the new lines
111
+ lines_new = []
112
+ for line in lines:
113
+ # profit of the old line
114
+ profit = Decimal(0)
115
+
116
+ # the values of the new line
117
+ values: list[
118
+ LedgerElement[Account | Literal["為替差益", "為替差損"], Literal[""]]
119
+ ] = []
120
+
121
+ # iterate over the values of the old line
122
+ for el in line.values:
123
+ amount_left = el.amount
124
+ # skip if the currency is empty (default, exchange rate = 1)
125
+ if el.currency == "":
126
+ values.append(el) # type: ignore
127
+ continue
128
+
129
+ # meaning of "quote": https://docs.ccxt.com/#/?id=market-structure
130
+ price_current = Decimal(str(prices_[el.currency][line.date]))
131
+ amount_in_quote = Decimal(0)
132
+ if amount_left < 0:
133
+ while amount_left < 0:
134
+ # the maximum amount to subtract from the first element
135
+ # of the balance[account][currency]
136
+ amount_substract = max(
137
+ -amount_left, balance[el.account][el.currency][0][1]
138
+ )
139
+ amount_in_quote -= (
140
+ amount_substract * balance[el.account][el.currency][0][0]
141
+ )
142
+ amount_left += amount_substract
143
+
144
+ # subtract the amount from the balance
145
+ balance[el.account][el.currency][0] = (
146
+ balance[el.account][el.currency][0][0],
147
+ balance[el.account][el.currency][0][1] - amount_substract,
148
+ )
149
+ # remove if the amount is zero
150
+ if balance[el.account][el.currency][0][1] == 0:
151
+ balance[el.account][el.currency].pop(0)
152
+ else:
153
+ balance[el.account][el.currency].append((price_current, amount_left))
154
+ amount_in_quote = amount_left * price_current
155
+
156
+ # round the amount_in_quote
157
+ with localcontext() as ctx:
158
+ ctx.rounding = ROUND_DOWN
159
+ amount_in_quote = round(amount_in_quote, 0)
160
+
161
+ # append to the values of the new line
162
+ values.append(
163
+ LedgerElementImpl(
164
+ account=el.account, amount=amount_in_quote, currency=""
165
+ )
166
+ )
167
+
168
+ # add to the profit
169
+ if is_debit(el.account) is True:
170
+ profit += amount_in_quote
171
+ else:
172
+ profit -= amount_in_quote
173
+
174
+ # append only if profit is not zero
175
+ if profit != 0:
176
+ values.append(
177
+ LedgerElementImpl(
178
+ account="為替差益" if profit > 0 else "為替差損",
179
+ amount=abs(profit),
180
+ currency="",
181
+ )
182
+ )
183
+
184
+ # append the new line
185
+ lines_new.append(GeneralLedgerLineImpl(date=line.date, values=values))
186
+ return lines_new
aoiro/_sheets.py ADDED
@@ -0,0 +1,123 @@
1
+ from collections.abc import Sequence
2
+ from itertools import groupby
3
+ from typing import Any
4
+
5
+ import networkx as nx
6
+
7
+ from ._ledger import Account, Currency, GeneralLedgerLine
8
+
9
+
10
+ def get_sheets(
11
+ lines: Sequence[GeneralLedgerLine[Account, Currency]],
12
+ G: nx.DiGraph,
13
+ *,
14
+ drop: bool = True,
15
+ ) -> nx.DiGraph:
16
+ """
17
+ Get the blue return accounts as a graph.
18
+
19
+ Returns
20
+ -------
21
+ nx.DiGraph
22
+ Tree representation of the blue return account list.
23
+ Has the following attributes:
24
+
25
+ sum: dict[Currency, Decimal]
26
+ The sum of the children for each currency,
27
+ with alternating signs for each AccountType.
28
+
29
+ sum_natural: dict[Currency, Decimal]
30
+ The sum of the children for each currency.
31
+ For accounts with AccountType well-defined, the sum is not
32
+ altered.
33
+ For accounts with AccountType None, the value is the same as sum.
34
+
35
+ """
36
+ G = G.copy()
37
+ values = [value for line in lines for value in line.values]
38
+ grouped = {
39
+ k: list(v)
40
+ for k, v in groupby(
41
+ sorted(values, key=lambda x: (x.account, x.currency)),
42
+ key=lambda x: (x.account, x.currency),
43
+ )
44
+ }
45
+ grouped_nested = {
46
+ k: dict(v) for k, v in groupby(grouped.items(), key=lambda x: x[0][0])
47
+ }
48
+
49
+ # Check that all accounts are in G
50
+ all_accounts = set(grouped_nested.keys())
51
+ all_accounts_G = {d["label"] for n, d in G.nodes(data=True) if not d["abstract"]}
52
+ if all_accounts - all_accounts_G:
53
+ raise ValueError(f"{all_accounts - all_accounts_G} not in G")
54
+
55
+ # non-abstract accounts
56
+ met_accounts: set[Any] = set()
57
+ for n in reversed(list(nx.topological_sort(G))):
58
+ d = G.nodes[n]
59
+ successors = list(G.successors(n))
60
+ add_current = (not d["abstract"]) and d["label"] in (
61
+ all_accounts - met_accounts
62
+ )
63
+ if successors or add_current:
64
+ G.nodes[n]["sum"] = _dict_sum(
65
+ [G.nodes[child]["sum"] for child in successors]
66
+ + (
67
+ [
68
+ {
69
+ currency: sum(el.amount for el in values)
70
+ * (1 if d["account_type"].debit else -1)
71
+ for (_, currency), values in grouped_nested[
72
+ d["label"]
73
+ ].items()
74
+ }
75
+ ]
76
+ if add_current
77
+ else []
78
+ )
79
+ )
80
+ if add_current:
81
+ met_accounts.add(d["label"])
82
+ else:
83
+ if drop:
84
+ G.remove_node(n)
85
+ else:
86
+ G.nodes[n]["sum"] = {}
87
+
88
+ # natural sum
89
+ for n, d in G.nodes(data=True):
90
+ account_type = d["account_type"]
91
+ if account_type is not None:
92
+ G.nodes[n]["sum_natural"] = {
93
+ k: v * (1 if account_type.debit else -1) for k, v in d["sum"].items()
94
+ }
95
+ else:
96
+ G.nodes[n]["sum_natural"] = d["sum"]
97
+ return G
98
+
99
+
100
+ def _dict_sum(
101
+ ds: Sequence[dict[Any, Any]],
102
+ /,
103
+ ) -> dict[Any, Any]:
104
+ """
105
+ Sum dictionaries.
106
+
107
+ Return a dictionary which,
108
+ for any key in any of the dictionaries,
109
+ contains the sum of the values of that key in all dictionaries
110
+ where the key is present.
111
+
112
+ Parameters
113
+ ----------
114
+ ds : Sequence[dict[Any, Any]]
115
+ The dictionaries to sum.
116
+
117
+ Returns
118
+ -------
119
+ dict[Any, Any]
120
+ The sum of the dictionaries.
121
+
122
+ """
123
+ return {k: sum([d.get(k, 0) for d in ds]) for k in set().union(*ds)}