Alilnvest-Codal 1.5.4__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.
- alilnvest_codal/__init__.py +167 -0
- alilnvest_codal/_version.py +2 -0
- alilnvest_codal/amendments.py +82 -0
- alilnvest_codal/analytics.py +174 -0
- alilnvest_codal/api.py +69 -0
- alilnvest_codal/audit.py +422 -0
- alilnvest_codal/automation.py +175 -0
- alilnvest_codal/cache.py +158 -0
- alilnvest_codal/checks.py +784 -0
- alilnvest_codal/codal.py +71 -0
- alilnvest_codal/config.py +105 -0
- alilnvest_codal/datasource.py +152 -0
- alilnvest_codal/endpoints.py +29 -0
- alilnvest_codal/excel.py +149 -0
- alilnvest_codal/exceptions.py +96 -0
- alilnvest_codal/formulas.py +333 -0
- alilnvest_codal/frames.py +139 -0
- alilnvest_codal/health.py +47 -0
- alilnvest_codal/http.py +752 -0
- alilnvest_codal/letter_codes.py +77 -0
- alilnvest_codal/models.py +241 -0
- alilnvest_codal/parsers.py +24 -0
- alilnvest_codal/progress.py +163 -0
- alilnvest_codal/py.typed +0 -0
- alilnvest_codal/reports.py +1199 -0
- alilnvest_codal/search.py +279 -0
- alilnvest_codal/sheets.py +305 -0
- alilnvest_codal/store.py +127 -0
- alilnvest_codal/utils.py +179 -0
- alilnvest_codal-1.5.4.dist-info/METADATA +183 -0
- alilnvest_codal-1.5.4.dist-info/RECORD +35 -0
- alilnvest_codal-1.5.4.dist-info/WHEEL +5 -0
- alilnvest_codal-1.5.4.dist-info/licenses/NOTICE.md +14 -0
- alilnvest_codal-1.5.4.dist-info/licenses/codal13_fixed_1.5.4-LICENSE +21 -0
- alilnvest_codal-1.5.4.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Alilnvest-Codal
|
|
3
|
+
=========
|
|
4
|
+
کتابخانهٔ مستقل Alilnvest برای کدال -- زنده، بدون لیست ثابت نماد.
|
|
5
|
+
|
|
6
|
+
اصولِ طراحی (حاصلِ یه بررسیِ چندمرحلهای رویِ منابع پژوهشی قبلی و کتابخانههای مشابه):
|
|
7
|
+
- نماد همیشه زنده روی search.codal.ir چک میشه -> نمادهای تازه (توسن) از روز اول کار میکنن.
|
|
8
|
+
- صورتهایِ مالیِ اصلی از متغیرِ جاواسکریپتِ `datasource` توی صفحهی گزارش خونده میشن، نه از دانلودِ اکسل.
|
|
9
|
+
- چندتا sheet (شرکتهایِ فرعی/وابسته، اعضایِ هیئتمدیره) اصلاً تویِ اون
|
|
10
|
+
datasource نیستن -- برایِ اونها ضمیمهی اکسلِ خودِ اطلاعیه با تطابقِ
|
|
11
|
+
دقیقِ اسمِ شیت خونده میشه (fetch_excel_sheet؛ نگاه کن به CHANGELOG ۰.۸.۹).
|
|
12
|
+
- سرفصلها با row_code ایندکس میشن (پایدارتر از تطبیقِ متنی).
|
|
13
|
+
- ردیفهای توضیحیِ بیمقدار (زیرِ جدول) بهصورتِ پیشفرض حذف میشن.
|
|
14
|
+
- قبل از گرفتنِ datasource، عنوانِ اطلاعیه چک میشه تا وقتِ اطلاعیههای غیرمالی هدر نره.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from ._version import __version__
|
|
18
|
+
|
|
19
|
+
BRAND = "Alilnvest"
|
|
20
|
+
DISTRIBUTION_NAME = "Alilnvest-Codal"
|
|
21
|
+
from .exceptions import CodalError, SymbolNotFoundError, DataSourceError, NetworkError, ExcelParseError, RateLimitError
|
|
22
|
+
from .http import reset_circuit_breaker, configure, clear_cache, safe_mode, fast_mode, symbol_pause, reset_symbol_pacing
|
|
23
|
+
from .letter_codes import LETTER_CODES, describe_letter_code
|
|
24
|
+
from .utils import validate_jdate
|
|
25
|
+
from .progress import ConsoleProgress, SearchProgress
|
|
26
|
+
from .models import Letter, DataSourceResult, FinancialReport, PortfolioReport, ReportQuality, SubsidiaryCompany, BoardMember, FinancialSnapshot
|
|
27
|
+
from .search import search_letters, get_letter, latest_letter
|
|
28
|
+
from .datasource import get_datasource, get_datasource_result, fetch_report_html
|
|
29
|
+
from .sheets import SheetOption, SheetContent, parse_sheet_menu, list_sheets, read_sheet
|
|
30
|
+
from .frames import cells_to_dataframe, cells_to_grid, apply_row_code_map
|
|
31
|
+
from .reports import (
|
|
32
|
+
fetch_financial_reports,
|
|
33
|
+
fetch_structured_sheet,
|
|
34
|
+
fetch_excel_sheet,
|
|
35
|
+
fetch_company_snapshot,
|
|
36
|
+
FULL_STATEMENT_SHEET_IDS,
|
|
37
|
+
income_statement,
|
|
38
|
+
balance_sheet,
|
|
39
|
+
monthly_activity,
|
|
40
|
+
cash_flow_statement,
|
|
41
|
+
equity_changes_statement,
|
|
42
|
+
comprehensive_income_statement,
|
|
43
|
+
subsidiary_companies,
|
|
44
|
+
affiliated_companies,
|
|
45
|
+
board_members,
|
|
46
|
+
latest_income,
|
|
47
|
+
latest_balance_sheet,
|
|
48
|
+
latest_cash_flow,
|
|
49
|
+
list_subsidiaries,
|
|
50
|
+
list_affiliates,
|
|
51
|
+
list_board_members,
|
|
52
|
+
financial_snapshot,
|
|
53
|
+
compare_report_data,
|
|
54
|
+
auditor_opinion_text,
|
|
55
|
+
fund_portfolio,
|
|
56
|
+
looks_like_financial_report,
|
|
57
|
+
looks_like_portfolio_report,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
from .checks import CheckResult, analyze_statement, analyze_set, analyze_portfolio, statement_kind, cash_flow_method
|
|
61
|
+
from .audit import SymbolAudit, audit_symbol, audit_symbols, write_reports, detect_kind
|
|
62
|
+
__all__ = [
|
|
63
|
+
"CheckResult", "analyze_statement", "analyze_set", "analyze_portfolio",
|
|
64
|
+
"statement_kind", "cash_flow_method", "SymbolAudit", "audit_symbol", "audit_symbols",
|
|
65
|
+
"write_reports", "detect_kind",
|
|
66
|
+
"CodalError",
|
|
67
|
+
"SymbolNotFoundError",
|
|
68
|
+
"DataSourceError",
|
|
69
|
+
"NetworkError",
|
|
70
|
+
"ExcelParseError",
|
|
71
|
+
"RateLimitError",
|
|
72
|
+
"reset_circuit_breaker",
|
|
73
|
+
"configure",
|
|
74
|
+
"clear_cache",
|
|
75
|
+
"safe_mode",
|
|
76
|
+
"fast_mode",
|
|
77
|
+
"symbol_pause",
|
|
78
|
+
"reset_symbol_pacing",
|
|
79
|
+
"LETTER_CODES",
|
|
80
|
+
"describe_letter_code",
|
|
81
|
+
"validate_jdate",
|
|
82
|
+
"SearchProgress",
|
|
83
|
+
"ConsoleProgress",
|
|
84
|
+
"Letter",
|
|
85
|
+
"DataSourceResult",
|
|
86
|
+
"FinancialReport",
|
|
87
|
+
"PortfolioReport",
|
|
88
|
+
"ReportQuality",
|
|
89
|
+
"SubsidiaryCompany",
|
|
90
|
+
"BoardMember",
|
|
91
|
+
"FinancialSnapshot",
|
|
92
|
+
"search_letters",
|
|
93
|
+
"get_letter",
|
|
94
|
+
"latest_letter",
|
|
95
|
+
"get_datasource",
|
|
96
|
+
"get_datasource_result",
|
|
97
|
+
"fetch_report_html",
|
|
98
|
+
"SheetOption",
|
|
99
|
+
"SheetContent",
|
|
100
|
+
"parse_sheet_menu",
|
|
101
|
+
"list_sheets",
|
|
102
|
+
"read_sheet",
|
|
103
|
+
"cells_to_dataframe",
|
|
104
|
+
"cells_to_grid",
|
|
105
|
+
"apply_row_code_map",
|
|
106
|
+
"fetch_financial_reports",
|
|
107
|
+
"fetch_structured_sheet",
|
|
108
|
+
"fetch_excel_sheet",
|
|
109
|
+
"fetch_company_snapshot",
|
|
110
|
+
"FULL_STATEMENT_SHEET_IDS",
|
|
111
|
+
"income_statement",
|
|
112
|
+
"balance_sheet",
|
|
113
|
+
"monthly_activity",
|
|
114
|
+
"cash_flow_statement",
|
|
115
|
+
"equity_changes_statement",
|
|
116
|
+
"comprehensive_income_statement",
|
|
117
|
+
"subsidiary_companies",
|
|
118
|
+
"affiliated_companies",
|
|
119
|
+
"board_members",
|
|
120
|
+
"latest_income",
|
|
121
|
+
"latest_balance_sheet",
|
|
122
|
+
"latest_cash_flow",
|
|
123
|
+
"list_subsidiaries",
|
|
124
|
+
"list_affiliates",
|
|
125
|
+
"list_board_members",
|
|
126
|
+
"financial_snapshot",
|
|
127
|
+
"compare_report_data",
|
|
128
|
+
"auditor_opinion_text",
|
|
129
|
+
"fund_portfolio",
|
|
130
|
+
"looks_like_financial_report",
|
|
131
|
+
"looks_like_portfolio_report",
|
|
132
|
+
"__version__",
|
|
133
|
+
"BRAND",
|
|
134
|
+
"DISTRIBUTION_NAME",
|
|
135
|
+
]
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
# 1.0 additions; the imports above intentionally remain for 0.9 compatibility.
|
|
139
|
+
from .api import (
|
|
140
|
+
async_balance_sheet,
|
|
141
|
+
async_income_statement,
|
|
142
|
+
balance_sheet as modern_balance_sheet,
|
|
143
|
+
income_statement as modern_income_statement,
|
|
144
|
+
)
|
|
145
|
+
from .cache import Cache
|
|
146
|
+
from .config import CodalConfig, DEFAULT_CONFIG
|
|
147
|
+
from .endpoints import BROWSER_HEADERS, CodalEndpoint
|
|
148
|
+
from .http import CodalHTTPClient
|
|
149
|
+
from .exceptions import CodalNetworkError, CodalNotFoundError, CodalParsingError
|
|
150
|
+
from .codal import Codal
|
|
151
|
+
from .health import health_check
|
|
152
|
+
from .automation import CodalRun, CodalBot, OperationResult, fetch_since
|
|
153
|
+
from .store import SQLiteStore, StoredDatasource
|
|
154
|
+
|
|
155
|
+
# New names are aliases at the package boundary; the legacy names above retain
|
|
156
|
+
# their richer optional arguments and historical report types.
|
|
157
|
+
__all__ += [
|
|
158
|
+
"CodalConfig", "DEFAULT_CONFIG", "Cache", "CodalEndpoint", "BROWSER_HEADERS",
|
|
159
|
+
"CodalHTTPClient", "CodalNetworkError", "CodalNotFoundError", "CodalParsingError",
|
|
160
|
+
"async_income_statement", "async_balance_sheet", "modern_income_statement", "modern_balance_sheet", "Codal", "health_check", "CodalRun", "CodalBot", "OperationResult", "fetch_since",
|
|
161
|
+
"SQLiteStore", "StoredDatasource",
|
|
162
|
+
]
|
|
163
|
+
# The stable 1.0 API must win at the package boundary. The underlying legacy
|
|
164
|
+
# implementation is still available as reports.income_statement and retains
|
|
165
|
+
# its historical optional arguments.
|
|
166
|
+
income_statement = modern_income_statement # noqa: F811 — legacy name intentionally replaced at package boundary
|
|
167
|
+
balance_sheet = modern_balance_sheet # noqa: F811 — legacy name intentionally replaced at package boundary
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""اصلاحیه: اطلاعیهی اصلاحشده را با اولیهاش میسنجد و میگوید اصلاحیه کدام عددها را عوض کرده است.
|
|
2
|
+
|
|
3
|
+
کدال گاهی یک اطلاعیه را «اصلاحیه» میکند و هر دو نسخه جداگانه منتشر میشوند. این ماژول نسخهی اولیهی همان دوره
|
|
4
|
+
را پیدا میکند (همان پایان دوره، همان نوع تلفیقی یا غیرتلفیقی، و بدون عبارت «اصلاحیه») و عددهایی را که فرق دارند
|
|
5
|
+
فهرست میکند. این ماژول خالص است؛ جستوجوی شبکه در audit.py انجام میشود.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any, Iterable
|
|
11
|
+
|
|
12
|
+
from .checks import CheckResult, column_layout, fa_digits, index_rows, period_of, row_key
|
|
13
|
+
|
|
14
|
+
__all__ = ["AMENDMENT_MARK", "is_amendment", "find_original", "differences", "compare_with_original"]
|
|
15
|
+
|
|
16
|
+
AMENDMENT_MARK = "اصلاحیه"
|
|
17
|
+
TOLERANCE = 1.0 # ریال
|
|
18
|
+
KIND_NAMES = {"balance": "ترازنامه", "income": "سود و زیان", "cash_flow": "جریان نقد"}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def is_amendment(report: Any) -> bool:
|
|
22
|
+
return AMENDMENT_MARK in row_key(getattr(report, "title", "") or "")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def find_original(current: Any, candidates: Iterable[Any]) -> Any | None:
|
|
26
|
+
"""اولین اطلاعیهای که همان پایان دوره و همان نوع (تلفیقی/غیرتلفیقی) را دارد و اصلاحیه نیست."""
|
|
27
|
+
matches = []
|
|
28
|
+
for rep in candidates:
|
|
29
|
+
if rep is None or rep.tracing_no == current.tracing_no or is_amendment(rep):
|
|
30
|
+
continue
|
|
31
|
+
if rep.period_end_to_date != current.period_end_to_date:
|
|
32
|
+
continue
|
|
33
|
+
if bool(rep.is_consolidated) != bool(current.is_consolidated):
|
|
34
|
+
continue
|
|
35
|
+
matches.append(rep)
|
|
36
|
+
if not matches:
|
|
37
|
+
return None
|
|
38
|
+
def sort_key(rep):
|
|
39
|
+
date = getattr(rep, "publish_date_time", None) or getattr(rep, "sent_date_time", None) or "~"
|
|
40
|
+
return (date, int(rep.tracing_no) if rep.tracing_no is not None else 2**63 - 1)
|
|
41
|
+
return min(matches, key=sort_key)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _values(report: Any) -> dict[tuple[str, str], tuple[str, float]]:
|
|
45
|
+
"""(کلید برچسب، تاریخ ستون) -> (برچسب، مقدار). اولین رخداد هر کلید نگه داشته میشود."""
|
|
46
|
+
layout = column_layout(report.raw)
|
|
47
|
+
out: dict[tuple[str, str], tuple[str, float]] = {}
|
|
48
|
+
for row in index_rows(report.row_code_df):
|
|
49
|
+
if not row["key"]:
|
|
50
|
+
continue
|
|
51
|
+
for col, value in row["vals"].items():
|
|
52
|
+
if col <= 1:
|
|
53
|
+
continue
|
|
54
|
+
date = period_of(layout, col)
|
|
55
|
+
if date:
|
|
56
|
+
out.setdefault((row["key"], date), (row["label"], value))
|
|
57
|
+
return out
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def differences(current: Any, original: Any, *, tolerance: float = TOLERANCE) -> list[dict[str, Any]]:
|
|
61
|
+
"""عددهایی که در اصلاحیه (current) با اطلاعیهی اولیه (original) فرق دارند."""
|
|
62
|
+
new, old = _values(current), _values(original)
|
|
63
|
+
diffs = []
|
|
64
|
+
for key in sorted(set(new) & set(old), key=lambda k: (k[1], k[0])):
|
|
65
|
+
label, before = old[key]
|
|
66
|
+
_, after = new[key]
|
|
67
|
+
if abs(before - after) > tolerance:
|
|
68
|
+
diffs.append({"label": label, "period": key[1], "original": before, "amended": after})
|
|
69
|
+
return diffs
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def compare_with_original(kind: str, current: Any, original: Any) -> CheckResult:
|
|
73
|
+
name = f"{KIND_NAMES.get(kind, kind)}: اصلاحیه در برابر اطلاعیهی اولیه"
|
|
74
|
+
head = f"TracingNo={current.tracing_no} (اصلاحیه) در برابر TracingNo={original.tracing_no} (اولیه)"
|
|
75
|
+
diffs = differences(current, original)
|
|
76
|
+
if not diffs:
|
|
77
|
+
return CheckResult("ok", "اصلاحیه", name, f"{head}: هیچ عددی تغییر نکرده است")
|
|
78
|
+
details = [f"{d['label']} | {fa_digits(d['period'])}: {d['original']:,.0f} → {d['amended']:,.0f}"
|
|
79
|
+
for d in diffs[:15]]
|
|
80
|
+
if len(diffs) > 15:
|
|
81
|
+
details.append(f"و {len(diffs) - 15} مورد دیگر")
|
|
82
|
+
return CheckResult("info", "اصلاحیه", name, f"{head}: {len(diffs)} عدد تغییر کرده است", tuple(details))
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""پاکسازی، مدلسازی، export و مقایسهی خروجیها -- لایهیِ کمکیِ اختیاری، بالایِ خروجیِ خامِ reports.py."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import math
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import pandas as pd
|
|
10
|
+
|
|
11
|
+
from .models import BoardMember, ReportQuality, SubsidiaryCompany
|
|
12
|
+
from .utils import normalize_fa
|
|
13
|
+
|
|
14
|
+
# نگاشتِ اسمِ ستونهایِ فارسیِ رایج (تویِ شیتهایِ «شرکت های فرعی»/«شرکت های
|
|
15
|
+
# وابسته») به اسمِ فیلدِ انگلیسیِ SubsidiaryCompany -- برایِ standardize_columns.
|
|
16
|
+
SUBSIDIARY_COLUMN_ALIASES = {
|
|
17
|
+
"نماد شرکت": "symbol",
|
|
18
|
+
"نام شرکت": "name",
|
|
19
|
+
"نماد ثبت شده نزد سازمان": "registered_symbol",
|
|
20
|
+
"وضعیت ثبت شده نزد سازمان": "registration_status",
|
|
21
|
+
"نوع فعالیت": "activity_type",
|
|
22
|
+
"حسابرس": "auditor",
|
|
23
|
+
"سال مالی": "fiscal_year",
|
|
24
|
+
"فعال/غیر فعال": "active_status",
|
|
25
|
+
"وضعیت": "status",
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _unique_columns(columns: list[Any]) -> list[str]:
|
|
30
|
+
"""نام ستونها را پایدار و یکتا میکند تا ستون تکراری بیصدا ادغام نشود."""
|
|
31
|
+
seen: dict[str, int] = {}
|
|
32
|
+
result: list[str] = []
|
|
33
|
+
for value in columns:
|
|
34
|
+
base = str(value).strip() or "column"
|
|
35
|
+
count = seen.get(base, 0)
|
|
36
|
+
seen[base] = count + 1
|
|
37
|
+
result.append(base if count == 0 else f"{base}_{count + 1}")
|
|
38
|
+
return result
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def clean_table(df: pd.DataFrame, *, drop_header: bool = True) -> tuple[pd.DataFrame, ReportQuality]:
|
|
42
|
+
"""
|
|
43
|
+
خروجیِ خامِ grid_df (که address-based ـه، نه row_code) رو تمیز میکنه:
|
|
44
|
+
ستونهایِ کاملاً خالی و ردیفهایِ کاملاً تکراری رو حذف میکنه، و اگه
|
|
45
|
+
drop_header=True بود و ردیفِ اول با حداقل ۲ تا از اسمهایِ شناختهشدهی
|
|
46
|
+
SUBSIDIARY_COLUMN_ALIASES مطابقت داشت، همون ردیف رو بهعنوانِ هدرِ
|
|
47
|
+
واقعی برمیداره (نه ستونهایِ عددیِ پیشفرضِ 0/1/2/...).
|
|
48
|
+
|
|
49
|
+
خروجیِ دوم (ReportQuality) شفاف میگه چقدر ردیف/ستون حذف شد -- تا کاربر
|
|
50
|
+
کورکورانه به تمیزکاری اعتماد نکنه.
|
|
51
|
+
"""
|
|
52
|
+
out = df.copy()
|
|
53
|
+
original_column_count = len(out.columns)
|
|
54
|
+
duplicate_rows = int(out.duplicated().sum())
|
|
55
|
+
|
|
56
|
+
out = out.dropna(axis=1, how="all").drop_duplicates().reset_index(drop=True)
|
|
57
|
+
|
|
58
|
+
if drop_header and not out.empty:
|
|
59
|
+
first_row_normalized = [normalize_fa(str(x)) for x in out.iloc[0].tolist()]
|
|
60
|
+
known_headers_normalized = {normalize_fa(k) for k in SUBSIDIARY_COLUMN_ALIASES}
|
|
61
|
+
if len(set(first_row_normalized) & known_headers_normalized) >= 2:
|
|
62
|
+
out.columns = _unique_columns(list(out.iloc[0]))
|
|
63
|
+
out = out.iloc[1:].reset_index(drop=True)
|
|
64
|
+
|
|
65
|
+
quality = ReportQuality(
|
|
66
|
+
"cleaned_table",
|
|
67
|
+
duplicate_rows_removed=duplicate_rows,
|
|
68
|
+
empty_columns_removed=original_column_count - len(out.columns),
|
|
69
|
+
)
|
|
70
|
+
return out, quality
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def standardize_columns(df: pd.DataFrame) -> pd.DataFrame:
|
|
74
|
+
"""ستونهایی که اسمِ فارسیِ شناختهشده دارن (SUBSIDIARY_COLUMN_ALIASES) رو به اسمِ فیلدِ انگلیسی تغییر میده؛ بقیه دستنخورده میمونن."""
|
|
75
|
+
out = df.copy()
|
|
76
|
+
aliases_normalized = {normalize_fa(k): v for k, v in SUBSIDIARY_COLUMN_ALIASES.items()}
|
|
77
|
+
out.columns = _unique_columns([aliases_normalized.get(normalize_fa(str(c)), str(c)) for c in out.columns])
|
|
78
|
+
return out
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def subsidiary_models(df: pd.DataFrame) -> list[SubsidiaryCompany]:
|
|
82
|
+
"""grid_dfِ تمیزشدهیِ شرکتهایِ فرعی/وابسته رو به لیستی از SubsidiaryCompany تبدیل میکنه."""
|
|
83
|
+
out = standardize_columns(df)
|
|
84
|
+
allowed_fields = set(SubsidiaryCompany.__dataclass_fields__) - {"raw"}
|
|
85
|
+
result: list[SubsidiaryCompany] = []
|
|
86
|
+
for _, row in out.iterrows():
|
|
87
|
+
values = {
|
|
88
|
+
field_name: (None if field_name not in row or pd.isna(row[field_name]) else str(row[field_name]))
|
|
89
|
+
for field_name in allowed_fields
|
|
90
|
+
}
|
|
91
|
+
result.append(SubsidiaryCompany(**values, raw=row.to_dict()))
|
|
92
|
+
return result
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def board_models(df: pd.DataFrame) -> list[BoardMember]:
|
|
96
|
+
"""
|
|
97
|
+
grid_dfِ تمیزشدهیِ اعضایِ هیئتمدیره رو به لیستی از BoardMember تبدیل
|
|
98
|
+
میکنه. اگه ستونِ "name"/"role" (بعدِ standardize_columns) پیدا نشد،
|
|
99
|
+
بهترتیب اولین/دومین ستون رو بهعنوانِ fallback بهکار میبره -- چون
|
|
100
|
+
اسمِ دقیقِ ستونهایِ این شیت هنوز تویِ SUBSIDIARY_COLUMN_ALIASES نیستن.
|
|
101
|
+
"""
|
|
102
|
+
out = standardize_columns(df)
|
|
103
|
+
columns = list(out.columns)
|
|
104
|
+
name_column = "name" if "name" in columns else (columns[0] if columns else None)
|
|
105
|
+
role_column = "role" if "role" in columns else (columns[1] if len(columns) > 1 else None)
|
|
106
|
+
|
|
107
|
+
result: list[BoardMember] = []
|
|
108
|
+
for _, row in out.iterrows():
|
|
109
|
+
name = None if name_column is None or pd.isna(row.get(name_column)) else str(row.get(name_column))
|
|
110
|
+
role = None if role_column is None or pd.isna(row.get(role_column)) else str(row.get(role_column))
|
|
111
|
+
result.append(BoardMember(name, role, row.to_dict()))
|
|
112
|
+
return result
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def dataframe_to_json(df: pd.DataFrame, path: str, *, metadata: dict | None = None, **kwargs: Any) -> None:
|
|
116
|
+
"""DataFrame رو به یه فایلِ JSON با ساختارِ {"metadata": ..., "rows": [...]} مینویسه (UTF-8، بدونِ escapeِ فارسی)."""
|
|
117
|
+
def json_safe(value: Any) -> Any:
|
|
118
|
+
if value is None or isinstance(value, (str, bool, int)):
|
|
119
|
+
return value
|
|
120
|
+
if isinstance(value, float):
|
|
121
|
+
return value if math.isfinite(value) else None
|
|
122
|
+
if isinstance(value, dict):
|
|
123
|
+
return {str(k): json_safe(v) for k, v in value.items()}
|
|
124
|
+
if isinstance(value, (list, tuple)):
|
|
125
|
+
return [json_safe(v) for v in value]
|
|
126
|
+
try:
|
|
127
|
+
if pd.isna(value):
|
|
128
|
+
return None
|
|
129
|
+
except (TypeError, ValueError):
|
|
130
|
+
pass
|
|
131
|
+
return str(value)
|
|
132
|
+
|
|
133
|
+
rows = df.where(pd.notna(df), None).to_dict(orient="records")
|
|
134
|
+
payload = {"metadata": json_safe(metadata or {}), "rows": json_safe(rows)}
|
|
135
|
+
kwargs.setdefault("ensure_ascii", False)
|
|
136
|
+
kwargs.setdefault("indent", 2)
|
|
137
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
138
|
+
json.dump(payload, f, **kwargs)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def compare_reports(old: Any, new: Any, *, key: str | None = None) -> dict[str, Any]:
|
|
142
|
+
"""
|
|
143
|
+
دو گزارش (یا مستقیم دو DataFrame) رو مقایسه میکنه -- `old`/`new` میتونن
|
|
144
|
+
خودِ FinancialReport/PortfolioReport باشن (grid_df/row_code_df ازشون
|
|
145
|
+
خونده میشه) یا مستقیم DataFrame.
|
|
146
|
+
|
|
147
|
+
اگه key بدی و تویِ هر دو ستون باشه، بر اساسِ اون ایندکس میکنه (مقایسهی
|
|
148
|
+
دقیقتر بر اساسِ شناسه، نه شمارهی ردیف). خروجی: کدوم ردیفها اضافه
|
|
149
|
+
شدن، کدوم حذف شدن، کدوم مقدارشون فرق کرده.
|
|
150
|
+
"""
|
|
151
|
+
old_df = getattr(old, "grid_df", getattr(old, "row_code_df", old)).copy()
|
|
152
|
+
new_df = getattr(new, "grid_df", getattr(new, "row_code_df", new)).copy()
|
|
153
|
+
|
|
154
|
+
if key and key in old_df.columns and key in new_df.columns:
|
|
155
|
+
old_df, new_df = old_df.set_index(key), new_df.set_index(key)
|
|
156
|
+
if not (old_df.index.is_unique and new_df.index.is_unique):
|
|
157
|
+
# با ایندکسِ تکراری، loc یک DataFrame برمیگرداند نه یک سطر؛ مقایسه بیمعنی میشود.
|
|
158
|
+
raise ValueError("compare_reports: شناسهها باید یکتا باشند (ردیفِ تکراری در یکی از گزارشها).")
|
|
159
|
+
|
|
160
|
+
old_keys, new_keys = set(old_df.index), set(new_df.index)
|
|
161
|
+
|
|
162
|
+
changed: list[dict[str, Any]] = []
|
|
163
|
+
for idx in sorted(old_keys & new_keys, key=str): # ترتیبِ قطعی برای خروجیِ قابلمقایسه
|
|
164
|
+
if not old_df.loc[idx].equals(new_df.loc[idx]):
|
|
165
|
+
changed.append({"key": idx, "old": old_df.loc[idx].to_dict(), "new": new_df.loc[idx].to_dict()})
|
|
166
|
+
|
|
167
|
+
added_keys = sorted(new_keys - old_keys, key=str)
|
|
168
|
+
removed_keys = sorted(old_keys - new_keys, key=str)
|
|
169
|
+
return {
|
|
170
|
+
# reset_index تا کلیدِ ردیف (همان «key» در changed) در خروجی بماند؛ با to_dict("records") خالی گم میشد
|
|
171
|
+
"added": new_df.loc[added_keys].rename_axis("key").reset_index().to_dict("records"),
|
|
172
|
+
"removed": old_df.loc[removed_keys].rename_axis("key").reset_index().to_dict("records"),
|
|
173
|
+
"changed": changed,
|
|
174
|
+
}
|
alilnvest_codal/api.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Stable 1.0 public API.
|
|
2
|
+
|
|
3
|
+
The legacy report engine remains underneath so existing advanced features keep
|
|
4
|
+
working while callers receive keyword-only, injectable entry points.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from .config import CodalConfig
|
|
12
|
+
from .http import CodalHTTPClient
|
|
13
|
+
from .reports import balance_sheet as _balance_sheet
|
|
14
|
+
from .reports import income_statement as _income_statement
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def income_statement(
|
|
18
|
+
symbol: str,
|
|
19
|
+
*,
|
|
20
|
+
from_date: str | None = None,
|
|
21
|
+
max_reports: int | None = None,
|
|
22
|
+
use_cache: bool = True,
|
|
23
|
+
client: CodalHTTPClient | None = None,
|
|
24
|
+
**kwargs: Any,
|
|
25
|
+
) -> list[Any]:
|
|
26
|
+
"""Return income statements, preserving the mature Codal parser behavior."""
|
|
27
|
+
options = dict(kwargs)
|
|
28
|
+
if from_date is not None:
|
|
29
|
+
options["from_date"] = from_date
|
|
30
|
+
if max_reports is not None:
|
|
31
|
+
options["max_reports"] = max_reports
|
|
32
|
+
options["use_cache"] = use_cache
|
|
33
|
+
options["client"] = client
|
|
34
|
+
options.setdefault("max_concurrency", 5)
|
|
35
|
+
return _income_statement(symbol, **options)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def balance_sheet(
|
|
39
|
+
symbol: str,
|
|
40
|
+
*,
|
|
41
|
+
from_date: str | None = None,
|
|
42
|
+
max_reports: int | None = None,
|
|
43
|
+
use_cache: bool = True,
|
|
44
|
+
client: CodalHTTPClient | None = None,
|
|
45
|
+
**kwargs: Any,
|
|
46
|
+
) -> list[Any]:
|
|
47
|
+
"""Return balance sheets with the same compatibility guarantees as 0.9."""
|
|
48
|
+
options = dict(kwargs)
|
|
49
|
+
if from_date is not None:
|
|
50
|
+
options["from_date"] = from_date
|
|
51
|
+
if max_reports is not None:
|
|
52
|
+
options["max_reports"] = max_reports
|
|
53
|
+
options["use_cache"] = use_cache
|
|
54
|
+
options["client"] = client
|
|
55
|
+
options.setdefault("max_concurrency", 5)
|
|
56
|
+
return _balance_sheet(symbol, **options)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
async def async_income_statement(symbol: str, **kwargs: Any) -> list[Any]:
|
|
60
|
+
"""Async facade for applications that need a non-blocking call site."""
|
|
61
|
+
return await asyncio.to_thread(income_statement, symbol, **kwargs)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
async def async_balance_sheet(symbol: str, **kwargs: Any) -> list[Any]:
|
|
65
|
+
"""Async facade for applications that need a non-blocking call site."""
|
|
66
|
+
return await asyncio.to_thread(balance_sheet, symbol, **kwargs)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
__all__ = ["income_statement", "balance_sheet", "async_income_statement", "async_balance_sheet", "CodalConfig"]
|