Upload 7 files
Browse files- app.py +96 -88
- config:risk_policies.yaml +11 -11
- finance_core.py +188 -130
- llm_extract.py +67 -64
- schemas.py +21 -13
app.py
CHANGED
|
@@ -1,65 +1,68 @@
|
|
| 1 |
-
#!/usr/bin/env
|
| 2 |
-
#-*- coding: utf-8 -*-
|
| 3 |
import os
|
| 4 |
import io
|
| 5 |
import json
|
| 6 |
import math
|
| 7 |
import base64
|
| 8 |
from datetime import datetime
|
| 9 |
-
from typing import List, Optional, Dict
|
| 10 |
|
| 11 |
import gradio as gr
|
| 12 |
from openai import OpenAI
|
| 13 |
from pydantic import BaseModel, Field, ValidationError
|
| 14 |
import yaml
|
| 15 |
|
| 16 |
-
from schemas import
|
| 17 |
-
from
|
| 18 |
-
|
| 19 |
credit_decision,
|
| 20 |
loan_decision,
|
| 21 |
-
|
| 22 |
build_report_dict,
|
| 23 |
)
|
| 24 |
-
from llm_extract import(
|
| 25 |
get_client,
|
| 26 |
upload_file_to_openai,
|
| 27 |
extract_financials_from_files,
|
| 28 |
suggest_multiples_with_llm,
|
| 29 |
)
|
| 30 |
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
|
|
|
|
|
|
| 35 |
|
| 36 |
def _load_policies() -> dict:
|
| 37 |
-
cfg_path= os.path.join(os.path.dirname(__file__),"config","risk_policies.yaml")
|
| 38 |
-
with open(cfg_path, "r",encoding="utf-8") as f:
|
| 39 |
return yaml.safe_load(f)
|
| 40 |
-
|
|
|
|
| 41 |
POLICIES = _load_policies()
|
| 42 |
|
| 43 |
|
| 44 |
-
def analyze(files: List[gr.File],company_name: str, industry_hint: str,
|
| 45 |
-
base_rate:float, want_credit:bool, want_loan: bool,want_invest: bool, debug:bool):
|
| 46 |
-
"""Main
|
| 47 |
try:
|
| 48 |
client = get_client()
|
| 49 |
except Exception as e:
|
| 50 |
raise gr.Error(str(e))
|
| 51 |
-
|
| 52 |
if not files or len(files) == 0:
|
| 53 |
-
raise gr.Error("決算書ファイル(PDF/画像)を
|
| 54 |
-
|
| 55 |
-
# 1)Upload files to OpenAI and extract
|
| 56 |
-
try
|
| 57 |
file_ids = []
|
| 58 |
-
for f in files
|
| 59 |
file_ids.append(upload_file_to_openai(client, f.name, f.read()))
|
| 60 |
except Exception as e:
|
| 61 |
-
raise gr.Error(f"ファイルのアップロードに失敗しました
|
| 62 |
-
|
| 63 |
try:
|
| 64 |
extract = extract_financials_from_files(
|
| 65 |
client=client,
|
|
@@ -70,16 +73,16 @@ def analyze(files: List[gr.File],company_name: str, industry_hint: str, currency
|
|
| 70 |
debug=debug,
|
| 71 |
)
|
| 72 |
except Exception as e:
|
| 73 |
-
raise gr.Error(f"LLM抽出に失敗しました
|
| 74 |
-
|
| 75 |
-
#allow override company name /
|
| 76 |
if company_name:
|
| 77 |
-
extract.company_name =company_name
|
| 78 |
if industry_hint:
|
| 79 |
extract.industry = industry_hint
|
| 80 |
|
| 81 |
-
# 2) Compute derived
|
| 82 |
-
ratios =
|
| 83 |
|
| 84 |
# 3) Decisions
|
| 85 |
decisions = {}
|
|
@@ -89,110 +92,115 @@ def analyze(files: List[gr.File],company_name: str, industry_hint: str, currency
|
|
| 89 |
decisions["loan"] = loan_decision(extract, ratios, base_rate or BASE_RATE, POLICIES)
|
| 90 |
if want_invest:
|
| 91 |
# Ask LLM for suggested multiples if industry present
|
| 92 |
-
|
| 93 |
try:
|
| 94 |
-
|
| 95 |
client=client,
|
| 96 |
text_model=TEXT_MODEL,
|
| 97 |
industry=extract.industry or industry_hint or "",
|
| 98 |
-
region="
|
| 99 |
debug=debug,
|
| 100 |
)
|
| 101 |
except Exception:
|
| 102 |
-
|
| 103 |
-
decisions["investment"] =
|
| 104 |
|
| 105 |
# 4) Build a combined report (dict) and return displays
|
| 106 |
report = build_report_dict(extract, ratios, decisions)
|
| 107 |
report_json = json.dumps(report, ensure_ascii=False, indent=2)
|
| 108 |
|
| 109 |
-
#Save a downloadable JSON
|
| 110 |
-
ts = datetime.utcnow().strftime("%
|
| 111 |
-
out_path =f"/mnt/data/report-{ts}.json"
|
| 112 |
-
with open(out_path,"w", encoding="utf-8") as f:
|
| 113 |
f.write(report_json)
|
| 114 |
|
| 115 |
# Pretty sections for Gradio
|
| 116 |
-
summary_md =[]
|
| 117 |
-
summary_md.append(f"### 企業名\n{extract.company_name or '
|
| 118 |
if extract.industry:
|
| 119 |
summary_md.append(f"### 業種(推定/指定)\n{extract.industry}")
|
|
|
|
|
|
|
| 120 |
if extract.fiscal_year_end:
|
| 121 |
-
summary_md.append(f"### 決算期末\n{extract.
|
| 122 |
|
| 123 |
summary_md.append("### 指標(主要)")
|
| 124 |
summary_md.append(
|
| 125 |
-
f"- 売上高:{ratios.get('revenue')}\n"
|
| 126 |
-
f"- 営業利益
|
| 127 |
-
f"- EBITDA:{ratios.get('ebitda')}\n"
|
| 128 |
-
f"- 当期純利益:{ratios.get('net_income')}\n"
|
| 129 |
-
f"- 流動比率:{ratios.get('current_ratio')}\n"
|
| 130 |
-
f"- 当座比率:{ratios.get('quick_ratio')}\n"
|
| 131 |
-
f"- D/Eレシオ:{ratios.get('debt_to_equity')}\n"
|
| 132 |
-
f"- インタレストカバレッジ:{ratios.get('
|
| 133 |
-
f"- 売上成長率:{ratios.get('revenue_growth_pct')}"
|
| 134 |
)
|
| 135 |
|
| 136 |
if "credit" in decisions:
|
| 137 |
c = decisions["credit"]
|
| 138 |
summary_md.append("### 与信判断(提案)")
|
| 139 |
summary_md.append(
|
| 140 |
-
f"- 与信ランク:**{c['
|
| 141 |
-
f"- 取引サイト:**{c['site_days']}日**\n"
|
| 142 |
-
f"- 取引可能上限:**{c
|
| 143 |
-
f"- 見直しタイミング:**{c['review_cycle']}**"
|
| 144 |
)
|
| 145 |
-
|
| 146 |
if "loan" in decisions:
|
| 147 |
l = decisions["loan"]
|
| 148 |
-
summary_md.append("### 融資判断(提案)"
|
| 149 |
summary_md.append(
|
| 150 |
-
f"- 融資上限額(概算):**{l['max_principal_display']}**\n"
|
| 151 |
-
f"- 期間案
|
| 152 |
-
f"- 参考金利:**{l['interest_rate_pct']}%**\n"
|
| 153 |
-
f"- 目標DSCR:**{l['target_dscr']}**"
|
| 154 |
)
|
| 155 |
-
|
| 156 |
if "investment" in decisions:
|
| 157 |
inv = decisions["investment"]
|
| 158 |
summary_md.append("### 投資判断(提案)")
|
| 159 |
summary_md.append(
|
| 160 |
-
f"- 推定企業価値
|
| 161 |
-
f"- 推定時価総額:**{inv['market_cap_display']}**\n"
|
| 162 |
-
f"- 想定投資レンジ:**{inv['recommended_check_size_display']}**\n"
|
| 163 |
-
f"- 魅力度:**{inv['attractiveness']}/5**
|
| 164 |
)
|
| 165 |
-
|
| 166 |
-
#Return
|
| 167 |
-
return "\n\n".join(summary_md),gr.JSON.update(value=json.loads(report_json)),out_path
|
|
|
|
| 168 |
|
| 169 |
def build_ui():
|
| 170 |
-
with gr.Blocks(theme
|
| 171 |
-
gr.Markdown("# 決算書→与信・融資・投資判断(HF+OpenAI
|
| 172 |
with gr.Row():
|
| 173 |
with gr.Column():
|
| 174 |
-
files = gr.File(label="決算書ファイル
|
| 175 |
company_name = gr.Textbox(label="会社名(任意)")
|
| 176 |
-
industry_hint = gr.Textbox(label="業種(任意
|
| 177 |
-
currency_hint = gr.Textbox(label="通
|
| 178 |
-
base_rate = gr.Number(label="ベース金利
|
| 179 |
-
want_credit = gr.Checkbox(label="与信判断",value=True)
|
| 180 |
-
want_loan = gr.Checkbox(label="融資判断",value=True)
|
| 181 |
-
want_invest = gr.Checkbox(label="投資判断",value=True)
|
| 182 |
-
debug = gr.Checkbox(label="デバッグモード
|
| 183 |
run_btn = gr.Button("分析する", variant="primary")
|
| 184 |
with gr.Column():
|
| 185 |
summary = gr.Markdown()
|
| 186 |
-
report = gr.JSON(label="詳細レポート
|
| 187 |
-
download = gr.File(label="レポート
|
| 188 |
|
| 189 |
-
run_btn.
|
| 190 |
analyze,
|
| 191 |
inputs=[files, company_name, industry_hint, currency_hint, base_rate, want_credit, want_loan, want_invest, debug],
|
| 192 |
-
outputs=[summary, report, download]
|
| 193 |
)
|
|
|
|
| 194 |
return demo
|
| 195 |
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
demo
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
# -*- coding: utf-8 -*-
|
| 3 |
import os
|
| 4 |
import io
|
| 5 |
import json
|
| 6 |
import math
|
| 7 |
import base64
|
| 8 |
from datetime import datetime
|
| 9 |
+
from typing import List, Optional, Dict, Any
|
| 10 |
|
| 11 |
import gradio as gr
|
| 12 |
from openai import OpenAI
|
| 13 |
from pydantic import BaseModel, Field, ValidationError
|
| 14 |
import yaml
|
| 15 |
|
| 16 |
+
from schemas import FinancialExtract, ExtractedPeriod, MultipleSuggestion
|
| 17 |
+
from finance_core import (
|
| 18 |
+
compute_ratios,
|
| 19 |
credit_decision,
|
| 20 |
loan_decision,
|
| 21 |
+
investment_decision,
|
| 22 |
build_report_dict,
|
| 23 |
)
|
| 24 |
+
from llm_extract import (
|
| 25 |
get_client,
|
| 26 |
upload_file_to_openai,
|
| 27 |
extract_financials_from_files,
|
| 28 |
suggest_multiples_with_llm,
|
| 29 |
)
|
| 30 |
|
| 31 |
+
|
| 32 |
+
HF_SPACE = os.environ.get("HF_SPACE_NAME", "hf-credit-loan-investment-app")
|
| 33 |
+
VISION_MODEL = os.environ.get("OPENAI_VISION_MODEL", "gpt-4o-mini")
|
| 34 |
+
TEXT_MODEL = os.environ.get("OPENAI_TEXT_MODEL", "gpt-4o-mini")
|
| 35 |
+
BASE_RATE = float(os.environ.get("BASE_RATE", "2.0")) # % per annum, configurable
|
| 36 |
+
|
| 37 |
|
| 38 |
def _load_policies() -> dict:
|
| 39 |
+
cfg_path = os.path.join(os.path.dirname(__file__), "config", "risk_policies.yaml")
|
| 40 |
+
with open(cfg_path, "r", encoding="utf-8") as f:
|
| 41 |
return yaml.safe_load(f)
|
| 42 |
+
|
| 43 |
+
|
| 44 |
POLICIES = _load_policies()
|
| 45 |
|
| 46 |
|
| 47 |
+
def analyze(files: List[gr.File], company_name: str, industry_hint: str, currency_hint: str,
|
| 48 |
+
base_rate: float, want_credit: bool, want_loan: bool, want_invest: bool, debug: bool):
|
| 49 |
+
"""Main pipeline used by the Gradio UI."""
|
| 50 |
try:
|
| 51 |
client = get_client()
|
| 52 |
except Exception as e:
|
| 53 |
raise gr.Error(str(e))
|
| 54 |
+
|
| 55 |
if not files or len(files) == 0:
|
| 56 |
+
raise gr.Error("決算書ファイル(PDF/画像)を1つ以上アップロードしてください。")
|
| 57 |
+
|
| 58 |
+
# 1) Upload files to OpenAI and extract structured financials via vision + Structured Outputs
|
| 59 |
+
try:
|
| 60 |
file_ids = []
|
| 61 |
+
for f in files:
|
| 62 |
file_ids.append(upload_file_to_openai(client, f.name, f.read()))
|
| 63 |
except Exception as e:
|
| 64 |
+
raise gr.Error(f"ファイルのアップロードに失敗しました: {e}")
|
| 65 |
+
|
| 66 |
try:
|
| 67 |
extract = extract_financials_from_files(
|
| 68 |
client=client,
|
|
|
|
| 73 |
debug=debug,
|
| 74 |
)
|
| 75 |
except Exception as e:
|
| 76 |
+
raise gr.Error(f"LLM抽出に失敗しました: {e}")
|
| 77 |
+
|
| 78 |
+
# allow override company name / industry if provided
|
| 79 |
if company_name:
|
| 80 |
+
extract.company_name = company_name
|
| 81 |
if industry_hint:
|
| 82 |
extract.industry = industry_hint
|
| 83 |
|
| 84 |
+
# 2) Compute derived ratios and risk score
|
| 85 |
+
ratios = compute_ratios(extract)
|
| 86 |
|
| 87 |
# 3) Decisions
|
| 88 |
decisions = {}
|
|
|
|
| 92 |
decisions["loan"] = loan_decision(extract, ratios, base_rate or BASE_RATE, POLICIES)
|
| 93 |
if want_invest:
|
| 94 |
# Ask LLM for suggested multiples if industry present
|
| 95 |
+
multiples: Optional[MultipleSuggestion] = None
|
| 96 |
try:
|
| 97 |
+
multiples = suggest_multiples_with_llm(
|
| 98 |
client=client,
|
| 99 |
text_model=TEXT_MODEL,
|
| 100 |
industry=extract.industry or industry_hint or "",
|
| 101 |
+
region="JP",
|
| 102 |
debug=debug,
|
| 103 |
)
|
| 104 |
except Exception:
|
| 105 |
+
multiples = None
|
| 106 |
+
decisions["investment"] = investment_decision(extract, ratios, POLICIES, multiples)
|
| 107 |
|
| 108 |
# 4) Build a combined report (dict) and return displays
|
| 109 |
report = build_report_dict(extract, ratios, decisions)
|
| 110 |
report_json = json.dumps(report, ensure_ascii=False, indent=2)
|
| 111 |
|
| 112 |
+
# Save a downloadable JSON
|
| 113 |
+
ts = datetime.utcnow().strftime("%Y%m%d-%H%M%S")
|
| 114 |
+
out_path = f"/mnt/data/report-{ts}.json"
|
| 115 |
+
with open(out_path, "w", encoding="utf-8") as f:
|
| 116 |
f.write(report_json)
|
| 117 |
|
| 118 |
# Pretty sections for Gradio
|
| 119 |
+
summary_md = []
|
| 120 |
+
summary_md.append(f"### 企業名\n{extract.company_name or '(不明)'}")
|
| 121 |
if extract.industry:
|
| 122 |
summary_md.append(f"### 業種(推定/指定)\n{extract.industry}")
|
| 123 |
+
if extract.currency:
|
| 124 |
+
summary_md.append(f"### 通貨\n{extract.currency}")
|
| 125 |
if extract.fiscal_year_end:
|
| 126 |
+
summary_md.append(f"### 決算期末\n{extract.fiscal_year_end}")
|
| 127 |
|
| 128 |
summary_md.append("### 指標(主要)")
|
| 129 |
summary_md.append(
|
| 130 |
+
f"- 売上高: {ratios.get('revenue')}\n"
|
| 131 |
+
f"- 営業利益(EBIT): {ratios.get('ebit')}\n"
|
| 132 |
+
f"- EBITDA: {ratios.get('ebitda')}\n"
|
| 133 |
+
f"- 当期純利益: {ratios.get('net_income')}\n"
|
| 134 |
+
f"- 流動比率: {ratios.get('current_ratio')}\n"
|
| 135 |
+
f"- 当座比率: {ratios.get('quick_ratio')}\n"
|
| 136 |
+
f"- D/Eレシオ: {ratios.get('debt_to_equity')}\n"
|
| 137 |
+
f"- インタレストカバレッジ: {ratios.get('interest_coverage')}\n"
|
| 138 |
+
f"- 売上成長率: {ratios.get('revenue_growth_pct')}"
|
| 139 |
)
|
| 140 |
|
| 141 |
if "credit" in decisions:
|
| 142 |
c = decisions["credit"]
|
| 143 |
summary_md.append("### 与信判断(提案)")
|
| 144 |
summary_md.append(
|
| 145 |
+
f"- 与信ランク: **{c['rating']}**(スコア {c['risk_score']}/100)\n"
|
| 146 |
+
f"- 取引サイト: **{c['site_days']}日**\n"
|
| 147 |
+
f"- 取引可能上限: **{c['transaction_limit_display']}**\n"
|
| 148 |
+
f"- 見直しタイミング: **{c['review_cycle']}**"
|
| 149 |
)
|
| 150 |
+
|
| 151 |
if "loan" in decisions:
|
| 152 |
l = decisions["loan"]
|
| 153 |
+
summary_md.append("### 融資判断(提案)")
|
| 154 |
summary_md.append(
|
| 155 |
+
f"- 融資上限額(概算): **{l['max_principal_display']}**\n"
|
| 156 |
+
f"- 期間案: **{l['term_years']}年**\n"
|
| 157 |
+
f"- 参考金利: **{l['interest_rate_pct']}%**\n"
|
| 158 |
+
f"- 目標DSCR: **{l['target_dscr']}**"
|
| 159 |
)
|
| 160 |
+
|
| 161 |
if "investment" in decisions:
|
| 162 |
inv = decisions["investment"]
|
| 163 |
summary_md.append("### 投資判断(提案)")
|
| 164 |
summary_md.append(
|
| 165 |
+
f"- 推定企業価値(EV): **{inv['ev_display']}**\n"
|
| 166 |
+
f"- 推定時価総額: **{inv['market_cap_display']}**\n"
|
| 167 |
+
f"- 想定投資レンジ: **{inv['recommended_check_size_display']}**\n"
|
| 168 |
+
f"- 魅力度: **{inv['attractiveness']}/5**(成長性: {inv['growth_label']})"
|
| 169 |
)
|
| 170 |
+
|
| 171 |
+
# Return
|
| 172 |
+
return "\n\n".join(summary_md), gr.JSON.update(value=json.loads(report_json)), out_path
|
| 173 |
+
|
| 174 |
|
| 175 |
def build_ui():
|
| 176 |
+
with gr.Blocks(theme=gr.themes.Soft(), css="footer {visibility: hidden}") as demo:
|
| 177 |
+
gr.Markdown("# 決算書→与信・融資・投資判断(HF+OpenAI)")
|
| 178 |
with gr.Row():
|
| 179 |
with gr.Column():
|
| 180 |
+
files = gr.File(label="決算書ファイル(PDF/JPG/PNG, 複数可)", file_types=[".pdf", ".png", ".jpg", ".jpeg"], file_count="multiple")
|
| 181 |
company_name = gr.Textbox(label="会社名(任意)")
|
| 182 |
+
industry_hint = gr.Textbox(label="業種(任意)")
|
| 183 |
+
currency_hint = gr.Textbox(label="通貨(任意, 例: JPY, USD)")
|
| 184 |
+
base_rate = gr.Number(label="ベース金利(%/年)", value=BASE_RATE)
|
| 185 |
+
want_credit = gr.Checkbox(label="与信判断", value=True)
|
| 186 |
+
want_loan = gr.Checkbox(label="融資判断", value=True)
|
| 187 |
+
want_invest = gr.Checkbox(label="投資判断", value=True)
|
| 188 |
+
debug = gr.Checkbox(label="デバッグモード(LLM抽出JSONを厳密化)", value=False)
|
| 189 |
run_btn = gr.Button("分析する", variant="primary")
|
| 190 |
with gr.Column():
|
| 191 |
summary = gr.Markdown()
|
| 192 |
+
report = gr.JSON(label="詳細レポート(JSON)")
|
| 193 |
+
download = gr.File(label="レポート(JSONダウンロード)")
|
| 194 |
|
| 195 |
+
run_btn.click(
|
| 196 |
analyze,
|
| 197 |
inputs=[files, company_name, industry_hint, currency_hint, base_rate, want_credit, want_loan, want_invest, debug],
|
| 198 |
+
outputs=[summary, report, download]
|
| 199 |
)
|
| 200 |
+
|
| 201 |
return demo
|
| 202 |
|
| 203 |
+
|
| 204 |
+
if __name__ == "__main__":
|
| 205 |
+
demo = build_ui()
|
| 206 |
+
demo.launch()
|
config:risk_policies.yaml
CHANGED
|
@@ -1,20 +1,20 @@
|
|
| 1 |
# Basic policy knobs (extend as needed)
|
| 2 |
-
liquidity
|
| 3 |
-
|
| 4 |
-
|
| 5 |
|
| 6 |
leverage:
|
| 7 |
-
|
| 8 |
-
|
| 9 |
|
| 10 |
coverage:
|
| 11 |
-
|
| 12 |
-
|
| 13 |
|
| 14 |
profitability:
|
| 15 |
-
|
| 16 |
-
|
| 17 |
|
| 18 |
growth:
|
| 19 |
-
|
| 20 |
-
|
|
|
|
| 1 |
# Basic policy knobs (extend as needed)
|
| 2 |
+
liquidity:
|
| 3 |
+
current_ratio_good: 2.0
|
| 4 |
+
current_ratio_ok: 1.5
|
| 5 |
|
| 6 |
leverage:
|
| 7 |
+
debt_to_equity_good: 0.5
|
| 8 |
+
debt_to_equity_ok: 1.0
|
| 9 |
|
| 10 |
coverage:
|
| 11 |
+
interest_coverage_good: 6.0
|
| 12 |
+
interest_coverage_ok: 3.0
|
| 13 |
|
| 14 |
profitability:
|
| 15 |
+
net_margin_good: 0.1
|
| 16 |
+
net_margin_ok: 0.03
|
| 17 |
|
| 18 |
growth:
|
| 19 |
+
revenue_growth_high: 0.2
|
| 20 |
+
revenue_growth_ok: 0.05
|
finance_core.py
CHANGED
|
@@ -1,31 +1,33 @@
|
|
| 1 |
# -*- coding: utf-8 -*-
|
| 2 |
from __future__ import annotations
|
| 3 |
import math
|
| 4 |
-
from typing import Dict, Any
|
| 5 |
from dataclasses import dataclass
|
| 6 |
|
| 7 |
-
from schemas import
|
| 8 |
|
| 9 |
-
|
|
|
|
| 10 |
if x is None:
|
| 11 |
-
return "
|
| 12 |
-
try
|
| 13 |
# compact format
|
| 14 |
-
absx=abs(x)
|
| 15 |
-
if absx >=1e12:
|
| 16 |
return f"{x/1e12:.2f}兆{currency}"
|
| 17 |
-
if absx >=1e8:
|
| 18 |
return f"{x/1e8:.2f}億{currency}"
|
| 19 |
-
if absx >=1e6:
|
| 20 |
return f"{x/1e6:.2f}百万{currency}"
|
| 21 |
-
return f"{x:,0f}{currency}"
|
| 22 |
except Exception:
|
| 23 |
-
return f"{x}{currency}"
|
|
|
|
| 24 |
|
| 25 |
def compute_ratios(extract: FinancialExtract) -> Dict[str, Any]:
|
| 26 |
-
"""
|
| 27 |
-
latest: Optional[ExtractedPeriod]= extract.latest()
|
| 28 |
-
prev: Optional[ExtractedPeriod]= extract.previous()
|
| 29 |
|
| 30 |
currency = extract.currency or ""
|
| 31 |
|
|
@@ -35,62 +37,66 @@ def compute_ratios(extract: FinancialExtract) -> Dict[str, Any]:
|
|
| 35 |
revenue = latest.revenue
|
| 36 |
cogs = latest.cogs
|
| 37 |
ebit = latest.ebit
|
| 38 |
-
ebitda = latest.ebitda if latest.ebitda is not None else (latest.ebit + (latest.depreciation or 0.0)if latest.ebit is not None else None)
|
| 39 |
net_income = latest.net_income
|
| 40 |
cash = latest.cash_and_equivalents
|
| 41 |
ar = latest.accounts_receivable
|
| 42 |
-
inv = latest.
|
| 43 |
-
ap = latest.
|
| 44 |
ca = latest.current_assets
|
| 45 |
-
cl = latest.
|
| 46 |
ta = latest.total_assets
|
| 47 |
te = latest.total_equity
|
|
|
|
| 48 |
interest = latest.interest_expense
|
| 49 |
|
| 50 |
gross_profit = (revenue - cogs) if revenue is not None and cogs is not None else None
|
| 51 |
-
gross_margin = (gross_profit/revenue)if revenue and gross_profit is not None else None
|
| 52 |
-
ebit_margin = (ebit/revenue)if revenue and ebit is not None else None
|
| 53 |
-
ebitda_margin = (ebitda /revenue) if revenue and ebitda is not None else None
|
| 54 |
|
| 55 |
-
current_ratio = (ca/cl) if ca and cl else None
|
| 56 |
quick_assets = (cash or 0.0) + (ar or 0.0)
|
| 57 |
-
quick_ratio = (quick_assets/cl)if quick_assets and cl else None
|
| 58 |
|
| 59 |
-
debt_to_equity = (td/te)if td is not None and te else None
|
| 60 |
-
interest_coverage = (ebit / interest)if ebit is not None and interest else None
|
| 61 |
|
| 62 |
ratios.update({
|
| 63 |
-
"revenue": _fmt_currency(revenue,currency),
|
| 64 |
-
"ebit":_fmt_currency(ebit, currency),
|
| 65 |
-
"ebitda":_fmt_currency(ebitda,currency),
|
| 66 |
-
"net_income":_fmt_currency(net_income,currency),
|
| 67 |
-
"gross_margin_pct":f"{gross_margin*100:.1f}%"if
|
| 68 |
-
"ebit_margin_pct": f"{ebit_margin*100:.1f}%"if
|
| 69 |
-
"
|
| 70 |
-
"
|
| 71 |
-
"
|
|
|
|
|
|
|
| 72 |
})
|
| 73 |
|
| 74 |
-
if latest and prev and latest.revenue and prev.
|
| 75 |
-
growth =(latest.revenue - prev.revenue)/ abs(prev.revenue)
|
| 76 |
ratios["revenue_growth_pct"] = f"{growth*100:.1f}%"
|
| 77 |
ratios["_revenue_growth_float"] = growth
|
| 78 |
else:
|
| 79 |
-
ratios["revenue_growth_pct"] = "
|
| 80 |
ratios["_revenue_growth_float"] = None
|
| 81 |
|
| 82 |
ratios["_latest_currency"] = extract.currency or "JPY"
|
| 83 |
ratios["_latest_revenue"] = latest.revenue if latest else None
|
| 84 |
-
ratios["
|
| 85 |
-
|
| 86 |
-
ratios["_latest_current_ratio"] = float(ratios["current_ratio"])if isinstance(ratios.get("current_ratio"), str) and ratios["current_ratio"]not in ("
|
| 87 |
-
ratios["
|
| 88 |
|
| 89 |
-
#Raw for computations
|
| 90 |
-
ratios["_latest"] = latest.dict()if latest else{}
|
| 91 |
return ratios
|
| 92 |
|
| 93 |
-
|
|
|
|
| 94 |
"""Simple additive score 0-100 based on policy thresholds."""
|
| 95 |
score = 50.0
|
| 96 |
|
|
@@ -102,9 +108,9 @@ def _risk_score(latest:ExtractedPeriod, ratios: Dict[str, Any], policies: Dict[s
|
|
| 102 |
score += 10
|
| 103 |
elif cr >= 1.5:
|
| 104 |
score += 6
|
| 105 |
-
elif cr >= 1.2
|
| 106 |
-
score +=3
|
| 107 |
-
elif cr< 1.0:
|
| 108 |
score -= 10
|
| 109 |
|
| 110 |
# Leverage
|
|
@@ -115,11 +121,11 @@ def _risk_score(latest:ExtractedPeriod, ratios: Dict[str, Any], policies: Dict[s
|
|
| 115 |
score += 8
|
| 116 |
elif de <= 1.0:
|
| 117 |
score += 4
|
| 118 |
-
elif de >2.0:
|
| 119 |
score -= 8
|
| 120 |
-
|
| 121 |
-
#Interest coverage
|
| 122 |
-
ebit,interest =latest.ebit, latest.interest_expense
|
| 123 |
if ebit is not None and interest:
|
| 124 |
ic = ebit / interest
|
| 125 |
if ic >= 6:
|
|
@@ -128,110 +134,161 @@ def _risk_score(latest:ExtractedPeriod, ratios: Dict[str, Any], policies: Dict[s
|
|
| 128 |
score += 6
|
| 129 |
elif ic >= 1.5:
|
| 130 |
score += 3
|
| 131 |
-
elif ic <1.0:
|
| 132 |
-
score -=12
|
| 133 |
|
| 134 |
# Profitability
|
| 135 |
revenue = latest.revenue
|
| 136 |
-
net = latest.
|
| 137 |
if revenue and net is not None:
|
| 138 |
-
nm = net /revenue
|
| 139 |
-
if nm
|
| 140 |
-
score +=6
|
| 141 |
elif nm >= 0.03:
|
| 142 |
score += 3
|
| 143 |
-
elif nm< 0:
|
| 144 |
score -= 8
|
| 145 |
-
|
| 146 |
-
#Growth
|
| 147 |
g = ratios.get("_revenue_growth_float")
|
| 148 |
if g is not None:
|
| 149 |
if g >= 0.2:
|
| 150 |
score += 6
|
| 151 |
elif g >= 0.05:
|
| 152 |
-
score +=3
|
| 153 |
elif g < 0:
|
| 154 |
score -= 5
|
| 155 |
|
| 156 |
-
return max
|
|
|
|
| 157 |
|
| 158 |
-
def _rating(score:float) -> str:
|
| 159 |
if score >= 85: return "A"
|
| 160 |
if score >= 70: return "B"
|
| 161 |
if score >= 55: return "C"
|
| 162 |
if score >= 40: return "D"
|
| 163 |
return "E"
|
| 164 |
|
| 165 |
-
|
| 166 |
-
|
|
|
|
| 167 |
currency = extract.currency or "JPY"
|
| 168 |
-
if not
|
| 169 |
-
return{
|
| 170 |
-
"
|
| 171 |
-
"risk_score":0,
|
| 172 |
-
"site_days":0,
|
| 173 |
-
"
|
| 174 |
-
"
|
|
|
|
| 175 |
}
|
| 176 |
-
|
| 177 |
-
score = _risk_score(lateset, ratios, policies)
|
| 178 |
-
rationg = _ _rating(score)
|
| 179 |
|
| 180 |
-
|
| 181 |
-
|
|
|
|
|
|
|
|
|
|
| 182 |
|
| 183 |
# Transaction limit heuristic
|
| 184 |
revenue = latest.revenue or 0.0
|
| 185 |
-
working_capital = (
|
| 186 |
-
liquidity_factor = 0.4 +0.6 *
|
| 187 |
-
risk_factor =0.5 + 0.5 *(score/100.0)
|
| 188 |
|
| 189 |
-
#Base: monthly revenue × (site/30) × buffer
|
| 190 |
-
site_days = site_map[
|
| 191 |
-
base_limit = (revenue/12.0) * (site_days/30.0) * 0.6
|
| 192 |
# Cap by working capital (can't exceed 40% of WC)
|
| 193 |
cap = max(0.0, working_capital) * 0.4
|
| 194 |
-
tx_limit = min(cap,base_limit * liquidity_factor * risk_factor)
|
| 195 |
tx_limit = max(0.0, tx_limit)
|
| 196 |
|
| 197 |
-
return{
|
| 198 |
-
"rating":rating,
|
| 199 |
-
"risk_score":
|
| 200 |
-
"site_days":site_days,
|
| 201 |
-
"
|
| 202 |
-
"
|
|
|
|
| 203 |
}
|
| 204 |
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
|
|
|
| 208 |
n = max(1, years)
|
| 209 |
if r <= 0:
|
| 210 |
-
return 1.0/n
|
| 211 |
-
return r/ (1-(1+r)**(-n))
|
|
|
|
| 212 |
|
| 213 |
-
def loan_decision(extract: FinancialExtract,
|
| 214 |
latest = extract.latest()
|
| 215 |
currency = extract.currency or "JPY"
|
| 216 |
if not latest:
|
| 217 |
-
return {"
|
| 218 |
-
"
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 222 |
# Growth label
|
| 223 |
-
g = ratios.get("
|
| 224 |
if g is None:
|
| 225 |
glabel = "Unknown"
|
| 226 |
elif g >= 0.25:
|
| 227 |
glabel = "High"
|
| 228 |
-
elif g >=0.05:
|
| 229 |
glabel = "Medium"
|
| 230 |
else:
|
| 231 |
glabel = "Low"
|
| 232 |
-
|
| 233 |
# Choose multiples
|
| 234 |
-
ebitda = latest.ebitda if latest.ebitda is not None else (latest.ebit + (latest.depreciation or 0.0)if latest.ebit is not None else None)
|
| 235 |
revenue = latest.revenue or 0.0
|
| 236 |
total_debt = latest.total_debt or 0.0
|
| 237 |
cash = latest.cash_and_equivalents or 0.0
|
|
@@ -240,18 +297,18 @@ def loan_decision(extract: FinancialExtract, tatios: Dict[str, Any],base_rate_pc
|
|
| 240 |
if multiples and ebitda and ebitda > 0:
|
| 241 |
ev = ebitda * multiples.ebitda_multiple
|
| 242 |
elif ebitda and ebitda > 0:
|
| 243 |
-
ev = ebitda * 8.0
|
| 244 |
else:
|
| 245 |
# fallback to revenue multiple
|
| 246 |
rev_mult = multiples.revenue_multiple if multiples else 1.5
|
| 247 |
ev = revenue * rev_mult
|
| 248 |
|
| 249 |
-
market_cap = max(0.0,ev - net_debt)
|
| 250 |
|
| 251 |
-
#Check size heuristic: 5
|
| 252 |
-
score =
|
| 253 |
rating = _rating(score)
|
| 254 |
-
pct = {"A":0.15,"B":0.12,"C":0.08,"D":0.04,"E":0.0}[rating]
|
| 255 |
check = market_cap * pct
|
| 256 |
|
| 257 |
# Attractiveness 1-5 based on score + growth
|
|
@@ -259,30 +316,31 @@ def loan_decision(extract: FinancialExtract, tatios: Dict[str, Any],base_rate_pc
|
|
| 259 |
if rating == "A":
|
| 260 |
attractiveness = 5 if glabel == "High" else 4
|
| 261 |
elif rating == "B":
|
| 262 |
-
attractiveness = 4 if glabel !
|
| 263 |
elif rating == "C":
|
| 264 |
-
attractiveness =
|
| 265 |
elif rating == "D":
|
| 266 |
attractiveness = 1
|
| 267 |
|
| 268 |
-
return{
|
| 269 |
-
"ev":ev, "ev_display":_fmt_currency(ev, currency),
|
| 270 |
-
"market_cap":market_cap, "market_cap_display":_fmt_currency(market_cap,currency),
|
| 271 |
-
"recommended_check_size":check, "
|
| 272 |
-
"attractiveness":attractiveness,
|
| 273 |
-
"growth_label":glabel,
|
| 274 |
}
|
| 275 |
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
"
|
| 281 |
-
"
|
| 282 |
-
"
|
|
|
|
| 283 |
},
|
| 284 |
-
"extracted":extract.dict()
|
| 285 |
-
"ratios":ratios,
|
| 286 |
-
"decisions":decisions,
|
| 287 |
-
"disclaimer":"本ツールはAIによる推定・一般的な計算式に基づく参考提案であり、投資勧誘・融資約定・与信保証を目的としたものではありません。最終判断は自己責任で、必要に応じて専門家の確認を行ってください。"
|
| 288 |
-
}
|
|
|
|
| 1 |
# -*- coding: utf-8 -*-
|
| 2 |
from __future__ import annotations
|
| 3 |
import math
|
| 4 |
+
from typing import Dict, Any, Optional
|
| 5 |
from dataclasses import dataclass
|
| 6 |
|
| 7 |
+
from schemas import FinancialExtract, ExtractedPeriod, MultipleSuggestion
|
| 8 |
|
| 9 |
+
|
| 10 |
+
def _fmt_currency(x: Optional[float], currency: str) -> str:
|
| 11 |
if x is None:
|
| 12 |
+
return "—"
|
| 13 |
+
try:
|
| 14 |
# compact format
|
| 15 |
+
absx = abs(x)
|
| 16 |
+
if absx >= 1e12:
|
| 17 |
return f"{x/1e12:.2f}兆{currency}"
|
| 18 |
+
if absx >= 1e8:
|
| 19 |
return f"{x/1e8:.2f}億{currency}"
|
| 20 |
+
if absx >= 1e6:
|
| 21 |
return f"{x/1e6:.2f}百万{currency}"
|
| 22 |
+
return f"{x:,.0f}{currency}"
|
| 23 |
except Exception:
|
| 24 |
+
return f"{x} {currency}"
|
| 25 |
+
|
| 26 |
|
| 27 |
def compute_ratios(extract: FinancialExtract) -> Dict[str, Any]:
|
| 28 |
+
"""Compute derived metrics from the latest period (and prior if exists)."""
|
| 29 |
+
latest: Optional[ExtractedPeriod] = extract.latest()
|
| 30 |
+
prev: Optional[ExtractedPeriod] = extract.previous()
|
| 31 |
|
| 32 |
currency = extract.currency or ""
|
| 33 |
|
|
|
|
| 37 |
revenue = latest.revenue
|
| 38 |
cogs = latest.cogs
|
| 39 |
ebit = latest.ebit
|
| 40 |
+
ebitda = latest.ebitda if latest.ebitda is not None else (latest.ebit + (latest.depreciation or 0.0) if latest.ebit is not None else None)
|
| 41 |
net_income = latest.net_income
|
| 42 |
cash = latest.cash_and_equivalents
|
| 43 |
ar = latest.accounts_receivable
|
| 44 |
+
inv = latest.inventory
|
| 45 |
+
ap = latest.accounts_payable
|
| 46 |
ca = latest.current_assets
|
| 47 |
+
cl = latest.current_liabilities
|
| 48 |
ta = latest.total_assets
|
| 49 |
te = latest.total_equity
|
| 50 |
+
td = latest.total_debt
|
| 51 |
interest = latest.interest_expense
|
| 52 |
|
| 53 |
gross_profit = (revenue - cogs) if revenue is not None and cogs is not None else None
|
| 54 |
+
gross_margin = (gross_profit / revenue) if revenue and gross_profit is not None else None
|
| 55 |
+
ebit_margin = (ebit / revenue) if revenue and ebit is not None else None
|
| 56 |
+
ebitda_margin = (ebitda / revenue) if revenue and ebitda is not None else None
|
| 57 |
|
| 58 |
+
current_ratio = (ca / cl) if ca and cl else None
|
| 59 |
quick_assets = (cash or 0.0) + (ar or 0.0)
|
| 60 |
+
quick_ratio = (quick_assets / cl) if quick_assets and cl else None
|
| 61 |
|
| 62 |
+
debt_to_equity = (td / te) if td is not None and te else None
|
| 63 |
+
interest_coverage = (ebit / interest) if ebit is not None and interest else None
|
| 64 |
|
| 65 |
ratios.update({
|
| 66 |
+
"revenue": _fmt_currency(revenue, currency),
|
| 67 |
+
"ebit": _fmt_currency(ebit, currency),
|
| 68 |
+
"ebitda": _fmt_currency(ebitda, currency),
|
| 69 |
+
"net_income": _fmt_currency(net_income, currency),
|
| 70 |
+
"gross_margin_pct": f"{gross_margin*100:.1f}%" if gross_margin is not None else "—",
|
| 71 |
+
"ebit_margin_pct": f"{ebit_margin*100:.1f}%" if ebit_margin is not None else "—",
|
| 72 |
+
"ebitda_margin_pct": f"{ebitda_margin*100:.1f}%" if ebitda_margin is not None else "—",
|
| 73 |
+
"current_ratio": f"{current_ratio:.2f}" if current_ratio is not None else "—",
|
| 74 |
+
"quick_ratio": f"{quick_ratio:.2f}" if quick_ratio is not None else "—",
|
| 75 |
+
"debt_to_equity": f"{debt_to_equity:.2f}" if debt_to_equity is not None else "—",
|
| 76 |
+
"interest_coverage": f"{interest_coverage:.2f}" if interest_coverage is not None else "—",
|
| 77 |
})
|
| 78 |
|
| 79 |
+
if latest and prev and latest.revenue and prev.revenue and prev.revenue != 0:
|
| 80 |
+
growth = (latest.revenue - prev.revenue) / abs(prev.revenue)
|
| 81 |
ratios["revenue_growth_pct"] = f"{growth*100:.1f}%"
|
| 82 |
ratios["_revenue_growth_float"] = growth
|
| 83 |
else:
|
| 84 |
+
ratios["revenue_growth_pct"] = "—"
|
| 85 |
ratios["_revenue_growth_float"] = None
|
| 86 |
|
| 87 |
ratios["_latest_currency"] = extract.currency or "JPY"
|
| 88 |
ratios["_latest_revenue"] = latest.revenue if latest else None
|
| 89 |
+
ratios["_latest_ebitda"] = (latest.ebitda if latest and latest.ebitda is not None else
|
| 90 |
+
(latest.ebit + (latest.depreciation or 0.0) if latest and latest.ebit is not None else None))
|
| 91 |
+
ratios["_latest_current_ratio"] = float(ratios["current_ratio"]) if isinstance(ratios.get("current_ratio"), str) and ratios["current_ratio"] not in ("—",) else None
|
| 92 |
+
ratios["_latest_quick_ratio"] = float(ratios["quick_ratio"]) if isinstance(ratios.get("quick_ratio"), str) and ratios["quick_ratio"] not in ("—",) else None
|
| 93 |
|
| 94 |
+
# Raw for computations
|
| 95 |
+
ratios["_latest"] = latest.dict() if latest else {}
|
| 96 |
return ratios
|
| 97 |
|
| 98 |
+
|
| 99 |
+
def _risk_score(latest: ExtractedPeriod, ratios: Dict[str, Any], policies: Dict[str, Any]) -> float:
|
| 100 |
"""Simple additive score 0-100 based on policy thresholds."""
|
| 101 |
score = 50.0
|
| 102 |
|
|
|
|
| 108 |
score += 10
|
| 109 |
elif cr >= 1.5:
|
| 110 |
score += 6
|
| 111 |
+
elif cr >= 1.2:
|
| 112 |
+
score += 3
|
| 113 |
+
elif cr < 1.0:
|
| 114 |
score -= 10
|
| 115 |
|
| 116 |
# Leverage
|
|
|
|
| 121 |
score += 8
|
| 122 |
elif de <= 1.0:
|
| 123 |
score += 4
|
| 124 |
+
elif de > 2.0:
|
| 125 |
score -= 8
|
| 126 |
+
|
| 127 |
+
# Interest coverage
|
| 128 |
+
ebit, interest = latest.ebit, latest.interest_expense
|
| 129 |
if ebit is not None and interest:
|
| 130 |
ic = ebit / interest
|
| 131 |
if ic >= 6:
|
|
|
|
| 134 |
score += 6
|
| 135 |
elif ic >= 1.5:
|
| 136 |
score += 3
|
| 137 |
+
elif ic < 1.0:
|
| 138 |
+
score -= 12
|
| 139 |
|
| 140 |
# Profitability
|
| 141 |
revenue = latest.revenue
|
| 142 |
+
net = latest.net_income
|
| 143 |
if revenue and net is not None:
|
| 144 |
+
nm = net / revenue
|
| 145 |
+
if nm >= 0.1:
|
| 146 |
+
score += 6
|
| 147 |
elif nm >= 0.03:
|
| 148 |
score += 3
|
| 149 |
+
elif nm < 0:
|
| 150 |
score -= 8
|
| 151 |
+
|
| 152 |
+
# Growth
|
| 153 |
g = ratios.get("_revenue_growth_float")
|
| 154 |
if g is not None:
|
| 155 |
if g >= 0.2:
|
| 156 |
score += 6
|
| 157 |
elif g >= 0.05:
|
| 158 |
+
score += 3
|
| 159 |
elif g < 0:
|
| 160 |
score -= 5
|
| 161 |
|
| 162 |
+
return max(0.0, min(100.0, score))
|
| 163 |
+
|
| 164 |
|
| 165 |
+
def _rating(score: float) -> str:
|
| 166 |
if score >= 85: return "A"
|
| 167 |
if score >= 70: return "B"
|
| 168 |
if score >= 55: return "C"
|
| 169 |
if score >= 40: return "D"
|
| 170 |
return "E"
|
| 171 |
|
| 172 |
+
|
| 173 |
+
def credit_decision(extract: FinancialExtract, ratios: Dict[str, Any], policies: Dict[str, Any]) -> Dict[str, Any]:
|
| 174 |
+
latest = extract.latest()
|
| 175 |
currency = extract.currency or "JPY"
|
| 176 |
+
if not latest:
|
| 177 |
+
return {
|
| 178 |
+
"rating": "E",
|
| 179 |
+
"risk_score": 0,
|
| 180 |
+
"site_days": 0,
|
| 181 |
+
"transaction_limit": 0.0,
|
| 182 |
+
"transaction_limit_display": _fmt_currency(0.0, currency),
|
| 183 |
+
"review_cycle": "取引前入金(与信不可)",
|
| 184 |
}
|
|
|
|
|
|
|
|
|
|
| 185 |
|
| 186 |
+
score = _risk_score(latest, ratios, policies)
|
| 187 |
+
rating = _rating(score)
|
| 188 |
+
|
| 189 |
+
site_map = {"A": 90, "B": 60, "C": 45, "D": 30, "E": 0}
|
| 190 |
+
review_map = {"A": "年1回", "B": "半年毎", "C": "四半期毎", "D": "月次", "E": "取引ごと"}
|
| 191 |
|
| 192 |
# Transaction limit heuristic
|
| 193 |
revenue = latest.revenue or 0.0
|
| 194 |
+
working_capital = (latest.current_assets or 0.0) - (latest.current_liabilities or 0.0)
|
| 195 |
+
liquidity_factor = 0.4 + 0.6 * min(1.0, max(0.0, (float(ratios.get("current_ratio", 0) if ratios.get("current_ratio") != "—" else 0) / 2.0)))
|
| 196 |
+
risk_factor = 0.5 + 0.5 * (score / 100.0)
|
| 197 |
|
| 198 |
+
# Base: monthly revenue × (site/30) × buffer
|
| 199 |
+
site_days = site_map[rating]
|
| 200 |
+
base_limit = (revenue / 12.0) * (site_days / 30.0) * 0.6
|
| 201 |
# Cap by working capital (can't exceed 40% of WC)
|
| 202 |
cap = max(0.0, working_capital) * 0.4
|
| 203 |
+
tx_limit = min(cap, base_limit * liquidity_factor * risk_factor)
|
| 204 |
tx_limit = max(0.0, tx_limit)
|
| 205 |
|
| 206 |
+
return {
|
| 207 |
+
"rating": rating,
|
| 208 |
+
"risk_score": round(score, 1),
|
| 209 |
+
"site_days": site_days,
|
| 210 |
+
"transaction_limit": tx_limit,
|
| 211 |
+
"transaction_limit_display": _fmt_currency(tx_limit, currency),
|
| 212 |
+
"review_cycle": review_map[rating],
|
| 213 |
}
|
| 214 |
|
| 215 |
+
|
| 216 |
+
def _annuity_factor(rate: float, years: int) -> float:
|
| 217 |
+
"""Annual amortization factor for an annuity loan, rate in %, years int."""
|
| 218 |
+
r = rate / 100.0
|
| 219 |
n = max(1, years)
|
| 220 |
if r <= 0:
|
| 221 |
+
return 1.0 / n
|
| 222 |
+
return r / (1 - (1 + r) ** (-n))
|
| 223 |
+
|
| 224 |
|
| 225 |
+
def loan_decision(extract: FinancialExtract, ratios: Dict[str, Any], base_rate_pct: float, policies: Dict[str, Any]) -> Dict[str, Any]:
|
| 226 |
latest = extract.latest()
|
| 227 |
currency = extract.currency or "JPY"
|
| 228 |
if not latest:
|
| 229 |
+
return {"max_principal": 0.0, "max_principal_display": _fmt_currency(0.0, currency),
|
| 230 |
+
"term_years": 0, "interest_rate_pct": base_rate_pct, "target_dscr": 0.0}
|
| 231 |
+
|
| 232 |
+
score = _risk_score(latest, ratios, policies)
|
| 233 |
+
rating = _rating(score)
|
| 234 |
+
|
| 235 |
+
# Risk premium by rating
|
| 236 |
+
rp = {"A": 0.5, "B": 1.0, "C": 2.0, "D": 3.5, "E": 0.0}[rating]
|
| 237 |
+
if rating == "E":
|
| 238 |
+
return {"max_principal": 0.0, "max_principal_display": _fmt_currency(0.0, currency),
|
| 239 |
+
"term_years": 0, "interest_rate_pct": 0.0, "target_dscr": 0.0}
|
| 240 |
+
|
| 241 |
+
interest_rate_pct = base_rate_pct + rp
|
| 242 |
+
term_years = {"A": 5, "B": 4, "C": 3, "D": 1}[rating]
|
| 243 |
+
target_dscr = {"A": 1.6, "B": 1.4, "C": 1.3, "D": 1.2}[rating]
|
| 244 |
+
|
| 245 |
+
ebitda = ratios.get("_latest_ebitda")
|
| 246 |
+
if not ebitda or ebitda <= 0:
|
| 247 |
+
return {"max_principal": 0.0, "max_principal_display": _fmt_currency(0.0, currency),
|
| 248 |
+
"term_years": term_years, "interest_rate_pct": interest_rate_pct, "target_dscr": target_dscr}
|
| 249 |
+
|
| 250 |
+
ann = _annuity_factor(interest_rate_pct, term_years)
|
| 251 |
+
annual_debt_service_cap = ebitda / target_dscr
|
| 252 |
+
max_principal = max(0.0, annual_debt_service_cap / ann)
|
| 253 |
+
|
| 254 |
+
# Cap by net tangible assets (if available): up to 50% of total assets - cash buffer
|
| 255 |
+
ta = latest.total_assets or 0.0
|
| 256 |
+
cash = latest.cash_and_equivalents or 0.0
|
| 257 |
+
nta_cap = max(0.0, (ta - cash) * 0.5)
|
| 258 |
+
max_principal = min(max_principal, nta_cap)
|
| 259 |
+
|
| 260 |
+
return {
|
| 261 |
+
"max_principal": max_principal,
|
| 262 |
+
"max_principal_display": _fmt_currency(max_principal, currency),
|
| 263 |
+
"term_years": term_years,
|
| 264 |
+
"interest_rate_pct": round(interest_rate_pct, 2),
|
| 265 |
+
"target_dscr": target_dscr,
|
| 266 |
+
}
|
| 267 |
+
|
| 268 |
+
|
| 269 |
+
def investment_decision(extract: FinancialExtract, ratios: Dict[str, Any], policies: Dict[str, Any],
|
| 270 |
+
multiples: Optional[MultipleSuggestion]) -> Dict[str, Any]:
|
| 271 |
+
latest = extract.latest()
|
| 272 |
+
currency = extract.currency or "JPY"
|
| 273 |
+
if not latest:
|
| 274 |
+
return {"ev": 0.0, "ev_display": _fmt_currency(0.0, currency),
|
| 275 |
+
"market_cap": 0.0, "market_cap_display": _fmt_currency(0.0, currency),
|
| 276 |
+
"recommended_check_size": 0.0, "recommended_check_size_display": _fmt_currency(0.0, currency),
|
| 277 |
+
"attractiveness": 1, "growth_label": "Low"}
|
| 278 |
+
|
| 279 |
# Growth label
|
| 280 |
+
g = ratios.get("_revenue_growth_float")
|
| 281 |
if g is None:
|
| 282 |
glabel = "Unknown"
|
| 283 |
elif g >= 0.25:
|
| 284 |
glabel = "High"
|
| 285 |
+
elif g >= 0.05:
|
| 286 |
glabel = "Medium"
|
| 287 |
else:
|
| 288 |
glabel = "Low"
|
| 289 |
+
|
| 290 |
# Choose multiples
|
| 291 |
+
ebitda = latest.ebitda if latest.ebitda is not None else (latest.ebit + (latest.depreciation or 0.0) if latest.ebit is not None else None)
|
| 292 |
revenue = latest.revenue or 0.0
|
| 293 |
total_debt = latest.total_debt or 0.0
|
| 294 |
cash = latest.cash_and_equivalents or 0.0
|
|
|
|
| 297 |
if multiples and ebitda and ebitda > 0:
|
| 298 |
ev = ebitda * multiples.ebitda_multiple
|
| 299 |
elif ebitda and ebitda > 0:
|
| 300 |
+
ev = ebitda * 8.0 # default
|
| 301 |
else:
|
| 302 |
# fallback to revenue multiple
|
| 303 |
rev_mult = multiples.revenue_multiple if multiples else 1.5
|
| 304 |
ev = revenue * rev_mult
|
| 305 |
|
| 306 |
+
market_cap = max(0.0, ev - net_debt)
|
| 307 |
|
| 308 |
+
# Check size heuristic: 5–15% of market cap depending on rating
|
| 309 |
+
score = _risk_score(latest, ratios, policies)
|
| 310 |
rating = _rating(score)
|
| 311 |
+
pct = {"A": 0.15, "B": 0.12, "C": 0.08, "D": 0.04, "E": 0.0}[rating]
|
| 312 |
check = market_cap * pct
|
| 313 |
|
| 314 |
# Attractiveness 1-5 based on score + growth
|
|
|
|
| 316 |
if rating == "A":
|
| 317 |
attractiveness = 5 if glabel == "High" else 4
|
| 318 |
elif rating == "B":
|
| 319 |
+
attractiveness = 4 if glabel != "Low" else 3
|
| 320 |
elif rating == "C":
|
| 321 |
+
attractiveness = 3 if glabel == "High" else 2
|
| 322 |
elif rating == "D":
|
| 323 |
attractiveness = 1
|
| 324 |
|
| 325 |
+
return {
|
| 326 |
+
"ev": ev, "ev_display": _fmt_currency(ev, currency),
|
| 327 |
+
"market_cap": market_cap, "market_cap_display": _fmt_currency(market_cap, currency),
|
| 328 |
+
"recommended_check_size": check, "recommended_check_size_display": _fmt_currency(check, currency),
|
| 329 |
+
"attractiveness": attractiveness,
|
| 330 |
+
"growth_label": glabel,
|
| 331 |
}
|
| 332 |
|
| 333 |
+
|
| 334 |
+
def build_report_dict(extract: FinancialExtract, ratios: Dict[str, Any], decisions: Dict[str, Any]) -> Dict[str, Any]:
|
| 335 |
+
return {
|
| 336 |
+
"metadata": {
|
| 337 |
+
"company_name": extract.company_name,
|
| 338 |
+
"industry": extract.industry,
|
| 339 |
+
"currency": extract.currency,
|
| 340 |
+
"fiscal_year_end": extract.fiscal_year_end,
|
| 341 |
},
|
| 342 |
+
"extracted": extract.dict(),
|
| 343 |
+
"ratios": ratios,
|
| 344 |
+
"decisions": decisions,
|
| 345 |
+
"disclaimer": "本ツールはAIによる推定・一般的な計算式に基づく参考提案であり、投資勧誘・融資約定・与信保証を目的としたものではありません。最終判断は自己責任で、必要に応じて専門家の確認を行ってください。",
|
| 346 |
+
}
|
llm_extract.py
CHANGED
|
@@ -1,7 +1,7 @@
|
|
| 1 |
# -*- coding: utf-8 -*-
|
| 2 |
from __future__ import annotations
|
| 3 |
import os
|
| 4 |
-
import
|
| 5 |
from typing import List, Optional
|
| 6 |
|
| 7 |
from openai import OpenAI
|
|
@@ -9,79 +9,82 @@ from pydantic import ValidationError
|
|
| 9 |
|
| 10 |
from schemas import FinancialExtract, MultipleSuggestion
|
| 11 |
|
| 12 |
-
|
| 13 |
-
|
|
|
|
|
|
|
| 14 |
|
| 15 |
def get_client() -> OpenAI:
|
| 16 |
key = os.environ.get("OPENAI_API_KEY")
|
| 17 |
if not key:
|
| 18 |
-
raise RuntimeError("OPENAI_API_KEYが未設定です。
|
| 19 |
return OpenAI(api_key=key, timeout=120)
|
| 20 |
|
| 21 |
-
|
|
|
|
| 22 |
"""Upload file bytes to OpenAI Files API and return file_id."""
|
| 23 |
from io import BytesIO
|
| 24 |
-
bio = BytesIO(
|
| 25 |
# Prefer 'vision', fallback to 'assistants' for SDK compatibility
|
| 26 |
try:
|
| 27 |
-
f = client.
|
| 28 |
except Exception:
|
| 29 |
-
f = client.files.create(file=("uploaded",bio), purpose="assistants")
|
| 30 |
return f.id
|
| 31 |
|
| 32 |
-
|
| 33 |
-
|
|
|
|
| 34 |
return FinancialExtract.model_json_schema()
|
| 35 |
|
| 36 |
-
|
| 37 |
-
|
|
|
|
| 38 |
debug: bool = False) -> FinancialExtract:
|
| 39 |
-
"""Use
|
| 40 |
system = (
|
| 41 |
-
"
|
| 42 |
-
"and
|
| 43 |
-
"If multiple statements exist, prefer consolidated figures."
|
| 44 |
-
"Return strictly valid JSON that conforms to the provided JSON schema."
|
| 45 |
-
"
|
| 46 |
)
|
| 47 |
-
|
| 48 |
user_text = (
|
| 49 |
-
"
|
| 50 |
-
"(revenue, COGS, EBIT, depreciation, EBITDA, net income, cash
|
| 51 |
-
"current assets, current liabilities, total assets, total equity, total debt, expense)."
|
| 52 |
-
"If both annual and quarterly are present, prefer annual."
|
| 53 |
-
"Do not compute new numbers except EBITDA =EBIT + depreciation when both are available."
|
| 54 |
)
|
| 55 |
if company_hint:
|
| 56 |
user_text += f"\nCompany hint: {company_hint}"
|
| 57 |
if currency_hint:
|
| 58 |
-
user_text += f"\nCurrency hint:{currency_hint}"
|
| 59 |
|
| 60 |
-
#Using the Responses API with Structured Outputs JSON schema
|
| 61 |
resp = client.responses.create(
|
| 62 |
model=model,
|
| 63 |
input=[
|
| 64 |
{
|
| 65 |
-
"role":"system"
|
| 66 |
-
"content":[{"
|
| 67 |
},
|
| 68 |
{
|
| 69 |
-
"role":"user",
|
| 70 |
-
"content":[{"type":"input_text","text":user_text}]+[
|
| 71 |
-
{"type":"input_file","
|
| 72 |
],
|
| 73 |
},
|
| 74 |
-
]
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
},
|
| 82 |
},
|
| 83 |
-
|
| 84 |
-
|
| 85 |
)
|
| 86 |
|
| 87 |
try:
|
|
@@ -94,65 +97,66 @@ def extract_financials_from_files(client: OpenAI, file_ids: List[str],company_hi
|
|
| 94 |
|
| 95 |
if not raw:
|
| 96 |
raise RuntimeError("LLMからの応答が空でした。")
|
| 97 |
-
|
| 98 |
try:
|
| 99 |
-
data =json.loads(raw)
|
| 100 |
except json.JSONDecodeError as e:
|
| 101 |
if not debug:
|
| 102 |
raw2 = raw.strip()
|
| 103 |
if raw2.startswith("```"):
|
| 104 |
-
raw2 = raw2.strip("`").split("\n",1)[-1]
|
| 105 |
-
|
| 106 |
else:
|
| 107 |
raise
|
| 108 |
|
| 109 |
try:
|
| 110 |
-
extract = FinancialExtract.model_validate(
|
| 111 |
except ValidationError as ve:
|
| 112 |
-
raise RuntimeError(f"構造化出力の検証に失敗
|
| 113 |
-
|
| 114 |
return extract
|
| 115 |
|
| 116 |
-
|
| 117 |
-
|
|
|
|
| 118 |
if not industry:
|
| 119 |
return None
|
| 120 |
-
|
| 121 |
-
system = "
|
| 122 |
user = (
|
| 123 |
-
f"Industry:{industry}\nRegion: {region}\n"
|
| 124 |
-
"Return a short JSON with keys: revenue_multiple(float),ebitda_multiple(float)."
|
| 125 |
"Use public-market small/mid-cap ranges where applicable and be conservative."
|
| 126 |
)
|
| 127 |
|
| 128 |
resp = client.responses.create(
|
| 129 |
model=text_model,
|
| 130 |
input=[
|
| 131 |
-
{"role":"system","content":[{"type":"input_text","text":system}]},
|
| 132 |
-
{"role":"user","content":[{"type":"input_text","text":user}]},
|
| 133 |
],
|
| 134 |
response_format={
|
| 135 |
-
"type":"json_schema",
|
| 136 |
-
"json_schema":{
|
| 137 |
-
"name":"MultipleSuggestion",
|
| 138 |
-
"schema":MultipleSuggestion.model_json_schema(),
|
| 139 |
-
"strict":True,
|
| 140 |
},
|
| 141 |
},
|
| 142 |
max_output_tokens=200,
|
| 143 |
)
|
| 144 |
|
| 145 |
try:
|
| 146 |
-
|
| 147 |
raw = resp.output_text
|
| 148 |
except Exception:
|
| 149 |
try:
|
| 150 |
raw = resp.output[0].content[0].text
|
| 151 |
except Exception:
|
| 152 |
raw = None
|
|
|
|
| 153 |
if not raw:
|
| 154 |
return None
|
| 155 |
-
|
| 156 |
try:
|
| 157 |
data = json.loads(raw)
|
| 158 |
return MultipleSuggestion.model_validate(data)
|
|
@@ -160,4 +164,3 @@ def suggest_multiples_with_llm(client: OpenAI, text_model:str, industry: str, re
|
|
| 160 |
if debug:
|
| 161 |
raise
|
| 162 |
return None
|
| 163 |
-
|
|
|
|
| 1 |
# -*- coding: utf-8 -*-
|
| 2 |
from __future__ import annotations
|
| 3 |
import os
|
| 4 |
+
import json
|
| 5 |
from typing import List, Optional
|
| 6 |
|
| 7 |
from openai import OpenAI
|
|
|
|
| 9 |
|
| 10 |
from schemas import FinancialExtract, MultipleSuggestion
|
| 11 |
|
| 12 |
+
|
| 13 |
+
VISION_MODEL = os.environ.get("OPENAI_VISION_MODEL", "gpt-4o-mini")
|
| 14 |
+
TEXT_MODEL = os.environ.get("OPENAI_TEXT_MODEL", "gpt-4o-mini")
|
| 15 |
+
|
| 16 |
|
| 17 |
def get_client() -> OpenAI:
|
| 18 |
key = os.environ.get("OPENAI_API_KEY")
|
| 19 |
if not key:
|
| 20 |
+
raise RuntimeError("OPENAI_API_KEY が未設定です。Spaces → Settings → Variables and secrets で追加してください。")
|
| 21 |
return OpenAI(api_key=key, timeout=120)
|
| 22 |
|
| 23 |
+
|
| 24 |
+
def upload_file_to_openai(client: OpenAI, filename: str, file_bytes: bytes) -> str:
|
| 25 |
"""Upload file bytes to OpenAI Files API and return file_id."""
|
| 26 |
from io import BytesIO
|
| 27 |
+
bio = BytesIO(file_bytes)
|
| 28 |
# Prefer 'vision', fallback to 'assistants' for SDK compatibility
|
| 29 |
try:
|
| 30 |
+
f = client.files.create(file=("uploaded", bio), purpose="vision")
|
| 31 |
except Exception:
|
| 32 |
+
f = client.files.create(file=("uploaded", bio), purpose="assistants")
|
| 33 |
return f.id
|
| 34 |
|
| 35 |
+
|
| 36 |
+
def _financial_schema_json():
|
| 37 |
+
# Pydantic -> JSON Schema
|
| 38 |
return FinancialExtract.model_json_schema()
|
| 39 |
|
| 40 |
+
|
| 41 |
+
def extract_financials_from_files(client: OpenAI, file_ids: List[str], company_hint: Optional[str],
|
| 42 |
+
currency_hint: Optional[str], model: str = VISION_MODEL,
|
| 43 |
debug: bool = False) -> FinancialExtract:
|
| 44 |
+
"""Use vision model to read statements and produce normalized financials (latest + previous)."""
|
| 45 |
system = (
|
| 46 |
+
"You are a meticulous financial analyst. Read the provided financial statement files "
|
| 47 |
+
"and extract the latest two fiscal periods' key items with units normalized to the statement currency. "
|
| 48 |
+
"If multiple statements exist, prefer consolidated figures. "
|
| 49 |
+
"Return strictly valid JSON that conforms to the provided JSON schema. "
|
| 50 |
+
"If any item is missing, set it to null rather than guessing."
|
| 51 |
)
|
|
|
|
| 52 |
user_text = (
|
| 53 |
+
"Extract the company name, currency, fiscal year end, and the latest two periods' numbers "
|
| 54 |
+
"(revenue, COGS, EBIT, depreciation, EBITDA, net income, cash, receivables, inventory, payables, "
|
| 55 |
+
"current assets, current liabilities, total assets, total equity, total debt, interest expense). "
|
| 56 |
+
"If both annual and quarterly are present, prefer annual. "
|
| 57 |
+
"Do not compute new numbers except EBITDA = EBIT + depreciation when both are available."
|
| 58 |
)
|
| 59 |
if company_hint:
|
| 60 |
user_text += f"\nCompany hint: {company_hint}"
|
| 61 |
if currency_hint:
|
| 62 |
+
user_text += f"\nCurrency hint: {currency_hint}"
|
| 63 |
|
| 64 |
+
# Using the Responses API with Structured Outputs JSON schema
|
| 65 |
resp = client.responses.create(
|
| 66 |
model=model,
|
| 67 |
input=[
|
| 68 |
{
|
| 69 |
+
"role": "system",
|
| 70 |
+
"content": [{"type": "input_text", "text": system}],
|
| 71 |
},
|
| 72 |
{
|
| 73 |
+
"role": "user",
|
| 74 |
+
"content": [{"type": "input_text", "text": user_text}] + [
|
| 75 |
+
{"type": "input_file", "file_id": fid} for fid in file_ids
|
| 76 |
],
|
| 77 |
},
|
| 78 |
+
],
|
| 79 |
+
response_format={
|
| 80 |
+
"type": "json_schema",
|
| 81 |
+
"json_schema": {
|
| 82 |
+
"name": "FinancialExtract",
|
| 83 |
+
"schema": _financial_schema_json(),
|
| 84 |
+
"strict": True,
|
|
|
|
| 85 |
},
|
| 86 |
+
},
|
| 87 |
+
max_output_tokens=2048,
|
| 88 |
)
|
| 89 |
|
| 90 |
try:
|
|
|
|
| 97 |
|
| 98 |
if not raw:
|
| 99 |
raise RuntimeError("LLMからの応答が空でした。")
|
| 100 |
+
|
| 101 |
try:
|
| 102 |
+
data = json.loads(raw)
|
| 103 |
except json.JSONDecodeError as e:
|
| 104 |
if not debug:
|
| 105 |
raw2 = raw.strip()
|
| 106 |
if raw2.startswith("```"):
|
| 107 |
+
raw2 = raw2.strip("`").split("\n", 1)[-1]
|
| 108 |
+
data = json.loads(raw2)
|
| 109 |
else:
|
| 110 |
raise
|
| 111 |
|
| 112 |
try:
|
| 113 |
+
extract = FinancialExtract.model_validate(data)
|
| 114 |
except ValidationError as ve:
|
| 115 |
+
raise RuntimeError(f"構造化出力の検証に失敗: {ve}")
|
| 116 |
+
|
| 117 |
return extract
|
| 118 |
|
| 119 |
+
|
| 120 |
+
def suggest_multiples_with_llm(client: OpenAI, text_model: str, industry: str, region: str = "JP", debug: bool = False) -> Optional[MultipleSuggestion]:
|
| 121 |
+
"""Ask a small text model to propose reasonable valuation multiples for the given industry/region."""
|
| 122 |
if not industry:
|
| 123 |
return None
|
| 124 |
+
|
| 125 |
+
system = "You are an equity analyst. Provide conservative valuation multiple estimates for the given industry and region."
|
| 126 |
user = (
|
| 127 |
+
f"Industry: {industry}\nRegion: {region}\n"
|
| 128 |
+
"Return a short JSON with keys: revenue_multiple (float), ebitda_multiple (float). "
|
| 129 |
"Use public-market small/mid-cap ranges where applicable and be conservative."
|
| 130 |
)
|
| 131 |
|
| 132 |
resp = client.responses.create(
|
| 133 |
model=text_model,
|
| 134 |
input=[
|
| 135 |
+
{"role": "system", "content": [{"type": "input_text", "text": system}]},
|
| 136 |
+
{"role": "user", "content": [{"type": "input_text", "text": user}]},
|
| 137 |
],
|
| 138 |
response_format={
|
| 139 |
+
"type": "json_schema",
|
| 140 |
+
"json_schema": {
|
| 141 |
+
"name": "MultipleSuggestion",
|
| 142 |
+
"schema": MultipleSuggestion.model_json_schema(),
|
| 143 |
+
"strict": True,
|
| 144 |
},
|
| 145 |
},
|
| 146 |
max_output_tokens=200,
|
| 147 |
)
|
| 148 |
|
| 149 |
try:
|
|
|
|
| 150 |
raw = resp.output_text
|
| 151 |
except Exception:
|
| 152 |
try:
|
| 153 |
raw = resp.output[0].content[0].text
|
| 154 |
except Exception:
|
| 155 |
raw = None
|
| 156 |
+
|
| 157 |
if not raw:
|
| 158 |
return None
|
| 159 |
+
|
| 160 |
try:
|
| 161 |
data = json.loads(raw)
|
| 162 |
return MultipleSuggestion.model_validate(data)
|
|
|
|
| 164 |
if debug:
|
| 165 |
raise
|
| 166 |
return None
|
|
|
schemas.py
CHANGED
|
@@ -3,9 +3,10 @@ from __future__ import annotations
|
|
| 3 |
from typing import Optional, List
|
| 4 |
from pydantic import BaseModel, Field
|
| 5 |
|
|
|
|
| 6 |
class ExtractedPeriod(BaseModel):
|
| 7 |
-
period_label: Optional[str] = Field(None, description
|
| 8 |
-
revenue:Optional[float] = None
|
| 9 |
cogs: Optional[float] = None
|
| 10 |
ebit: Optional[float] = None
|
| 11 |
depreciation: Optional[float] = None
|
|
@@ -14,28 +15,35 @@ class ExtractedPeriod(BaseModel):
|
|
| 14 |
|
| 15 |
cash_and_equivalents: Optional[float] = None
|
| 16 |
accounts_receivable: Optional[float] = None
|
| 17 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
total_equity: Optional[float] = None
|
| 19 |
total_debt: Optional[float] = None
|
| 20 |
-
interest_expense:Optional[float] = None
|
|
|
|
| 21 |
|
| 22 |
class FinancialExtract(BaseModel):
|
| 23 |
company_name: Optional[str] = None
|
| 24 |
industry: Optional[str] = None
|
| 25 |
-
currency: Optional[str] = None
|
| 26 |
-
fiscal_year_end: Optional[str] = None
|
| 27 |
-
|
| 28 |
|
| 29 |
def latest(self) -> Optional[ExtractedPeriod]:
|
| 30 |
if not self.periods:
|
| 31 |
return None
|
| 32 |
-
return self.
|
| 33 |
-
|
| 34 |
-
def
|
| 35 |
if not self.periods or len(self.periods) < 2:
|
| 36 |
return None
|
| 37 |
-
return self.
|
| 38 |
-
|
|
|
|
| 39 |
class MultipleSuggestion(BaseModel):
|
| 40 |
revenue_multiple: float = 1.5
|
| 41 |
-
ebitda_multiple: float =8.0
|
|
|
|
| 3 |
from typing import Optional, List
|
| 4 |
from pydantic import BaseModel, Field
|
| 5 |
|
| 6 |
+
|
| 7 |
class ExtractedPeriod(BaseModel):
|
| 8 |
+
period_label: Optional[str] = Field(None, description="e.g., FY2024, FY2023")
|
| 9 |
+
revenue: Optional[float] = None
|
| 10 |
cogs: Optional[float] = None
|
| 11 |
ebit: Optional[float] = None
|
| 12 |
depreciation: Optional[float] = None
|
|
|
|
| 15 |
|
| 16 |
cash_and_equivalents: Optional[float] = None
|
| 17 |
accounts_receivable: Optional[float] = None
|
| 18 |
+
inventory: Optional[float] = None
|
| 19 |
+
accounts_payable: Optional[float] = None
|
| 20 |
+
|
| 21 |
+
current_assets: Optional[float] = None
|
| 22 |
+
current_liabilities: Optional[float] = None
|
| 23 |
+
total_assets: Optional[float] = None
|
| 24 |
total_equity: Optional[float] = None
|
| 25 |
total_debt: Optional[float] = None
|
| 26 |
+
interest_expense: Optional[float] = None
|
| 27 |
+
|
| 28 |
|
| 29 |
class FinancialExtract(BaseModel):
|
| 30 |
company_name: Optional[str] = None
|
| 31 |
industry: Optional[str] = None
|
| 32 |
+
currency: Optional[str] = None # e.g., JPY, USD
|
| 33 |
+
fiscal_year_end: Optional[str] = None # YYYY-MM-DD if known
|
| 34 |
+
periods: List[ExtractedPeriod]
|
| 35 |
|
| 36 |
def latest(self) -> Optional[ExtractedPeriod]:
|
| 37 |
if not self.periods:
|
| 38 |
return None
|
| 39 |
+
return self.periods[0]
|
| 40 |
+
|
| 41 |
+
def previous(self) -> Optional[ExtractedPeriod]:
|
| 42 |
if not self.periods or len(self.periods) < 2:
|
| 43 |
return None
|
| 44 |
+
return self.periods[1]
|
| 45 |
+
|
| 46 |
+
|
| 47 |
class MultipleSuggestion(BaseModel):
|
| 48 |
revenue_multiple: float = 1.5
|
| 49 |
+
ebitda_multiple: float = 8.0
|