|
|
|
|
|
from __future__ import annotations |
|
|
from typing import Dict, Any, Optional, List, Tuple |
|
|
|
|
|
from schemas import FinancialExtract, ExtractedPeriod, MultipleSuggestion, MarketOutlook |
|
|
|
|
|
def _fmt_currency(x, currency: str) -> str: |
|
|
if x is None: |
|
|
return "—" |
|
|
try: |
|
|
absx = abs(x) |
|
|
if absx >= 1e12: return f"{x/1e12:.2f}兆{currency}" |
|
|
if absx >= 1e8: return f"{x/1e8:.2f}億{currency}" |
|
|
if absx >= 1e6: return f"{x/1e6:.2f}百万{currency}" |
|
|
return f"{x:,.0f}{currency}" |
|
|
except Exception: |
|
|
return f"{x} {currency}" |
|
|
|
|
|
def compute_ratios(extract: FinancialExtract) -> Dict[str, Any]: |
|
|
latest: Optional[ExtractedPeriod] = extract.latest() |
|
|
prev: Optional[ExtractedPeriod] = extract.previous() |
|
|
currency = extract.currency or "" |
|
|
ratios: Dict[str, Any] = {} |
|
|
|
|
|
if latest: |
|
|
revenue = latest.revenue |
|
|
cogs = latest.cogs |
|
|
ebit = latest.ebit |
|
|
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) |
|
|
net_income = latest.net_income |
|
|
cash = latest.cash_and_equivalents |
|
|
ar = latest.accounts_receivable |
|
|
inv = latest.inventory |
|
|
ap = latest.accounts_payable |
|
|
ca = latest.current_assets |
|
|
cl = latest.current_liabilities |
|
|
te = latest.total_equity |
|
|
td = latest.total_debt |
|
|
interest = latest.interest_expense |
|
|
ta = latest.total_assets |
|
|
|
|
|
gross_profit = (revenue - cogs) if revenue is not None and cogs is not None else None |
|
|
gross_margin = (gross_profit / revenue) if revenue and gross_profit is not None else None |
|
|
ebit_margin = (ebit / revenue) if revenue and ebit is not None else None |
|
|
ebitda_margin = (ebitda / revenue) if revenue and ebitda is not None else None |
|
|
|
|
|
current_ratio = (ca / cl) if ca and cl else None |
|
|
quick_assets = (cash or 0.0) + (ar or 0.0) |
|
|
quick_ratio = (quick_assets / cl) if quick_assets and cl else None |
|
|
|
|
|
debt_to_equity = (td / te) if td is not None and te else None |
|
|
interest_coverage = (ebit / interest) if ebit is not None and interest else None |
|
|
|
|
|
|
|
|
roe = (net_income / te) if net_income is not None and te else None |
|
|
roa = (net_income / ta) if net_income is not None and ta else None |
|
|
cash_ratio = (cash / cl) if cash is not None and cl else None |
|
|
net_debt = (td - (cash or 0.0)) if td is not None else None |
|
|
|
|
|
dso = ( (ar / (revenue/365.0)) if ar is not None and revenue else None ) |
|
|
dio = ( (inv / ( (cogs or 0.0)/365.0)) if inv is not None and cogs else None ) |
|
|
dpo = ( (ap / ( (cogs or 0.0)/365.0)) if ap is not None and cogs else None ) |
|
|
ccc = ( (dso or 0) + (dio or 0) - (dpo or 0) ) if (dso is not None and dio is not None and dpo is not None) else None |
|
|
|
|
|
ratios.update({ |
|
|
"revenue": _fmt_currency(revenue, currency), |
|
|
"ebit": _fmt_currency(ebit, currency), |
|
|
"ebitda": _fmt_currency(ebitda, currency), |
|
|
"net_income": _fmt_currency(net_income, currency), |
|
|
"gross_margin_pct": f"{gross_margin*100:.1f}%" if gross_margin is not None else "—", |
|
|
"ebit_margin_pct": f"{ebit_margin*100:.1f}%" if ebit_margin is not None else "—", |
|
|
"ebitda_margin_pct": f"{ebitda_margin*100:.1f}%" if ebitda_margin is not None else "—", |
|
|
"current_ratio": f"{current_ratio:.2f}" if current_ratio is not None else "—", |
|
|
"quick_ratio": f"{quick_ratio:.2f}" if quick_ratio is not None else "—", |
|
|
"debt_to_equity": f"{debt_to_equity:.2f}" if debt_to_equity is not None else "—", |
|
|
"interest_coverage": f"{interest_coverage:.2f}" if interest_coverage is not None else "—", |
|
|
|
|
|
"roe_pct": f"{roe*100:.1f}%" if roe is not None else "—", |
|
|
"roa_pct": f"{roa*100:.1f}%" if roa is not None else "—", |
|
|
"cash_ratio": f"{cash_ratio:.2f}" if cash_ratio is not None else "—", |
|
|
"net_debt": _fmt_currency(net_debt, currency) if net_debt is not None else "—", |
|
|
"dso_days": f"{dso:.0f}日" if dso is not None else "—", |
|
|
"dio_days": f"{dio:.0f}日" if dio is not None else "—", |
|
|
"dpo_days": f"{dpo:.0f}日" if dpo is not None else "—", |
|
|
"ccc_days": f"{ccc:.0f}日" if ccc is not None else "—", |
|
|
}) |
|
|
|
|
|
if latest and prev and latest.revenue and prev.revenue and prev.revenue != 0: |
|
|
growth = (latest.revenue - prev.revenue) / abs(prev.revenue) |
|
|
ratios["revenue_growth_pct"] = f"{growth*100:.1f}%" |
|
|
ratios["_revenue_growth_float"] = growth |
|
|
else: |
|
|
ratios["revenue_growth_pct"] = "—" |
|
|
ratios["_revenue_growth_float"] = None |
|
|
|
|
|
ratios["_latest_currency"] = extract.currency or "JPY" |
|
|
ratios["_latest_ebitda"] = (latest.ebitda if latest and latest.ebitda is not None else |
|
|
(latest.ebit + (latest.depreciation or 0.0) if latest and latest.ebit is not None else None)) |
|
|
ratios["_latest"] = latest.dict() if latest else {} |
|
|
return ratios |
|
|
|
|
|
def _risk_score(latest: ExtractedPeriod, ratios: Dict[str, Any], policies: Dict[str, Any]) -> float: |
|
|
score = 50.0 |
|
|
ca, cl = latest.current_assets, latest.current_liabilities |
|
|
if ca and cl: |
|
|
cr = ca / cl |
|
|
if cr >= 2.0: score += 10 |
|
|
elif cr >= 1.5: score += 6 |
|
|
elif cr >= 1.2: score += 3 |
|
|
elif cr < 1.0: score -= 10 |
|
|
|
|
|
td, te = latest.total_debt, latest.total_equity |
|
|
if td is not None and te: |
|
|
de = td / te |
|
|
if de <= 0.5: score += 8 |
|
|
elif de <= 1.0: score += 4 |
|
|
elif de > 2.0: score -= 8 |
|
|
|
|
|
ebit, interest = latest.ebit, latest.interest_expense |
|
|
if ebit is not None and interest: |
|
|
ic = ebit / interest |
|
|
if ic >= 6: score += 10 |
|
|
elif ic >= 3: score += 6 |
|
|
elif ic >= 1.5: score += 3 |
|
|
elif ic < 1.0: score -= 12 |
|
|
|
|
|
revenue = latest.revenue |
|
|
net = latest.net_income |
|
|
if revenue and net is not None: |
|
|
nm = net / revenue |
|
|
if nm >= 0.1: score += 6 |
|
|
elif nm >= 0.03: score += 3 |
|
|
elif nm < 0: score -= 8 |
|
|
|
|
|
g = ratios.get("_revenue_growth_float") |
|
|
if g is not None: |
|
|
if g >= 0.2: score += 6 |
|
|
elif g >= 0.05: score += 3 |
|
|
elif g < 0: score -= 5 |
|
|
return max(0.0, min(100.0, score)) |
|
|
|
|
|
def _rating(score: float) -> str: |
|
|
if score >= 85: return "A" |
|
|
if score >= 70: return "B" |
|
|
if score >= 55: return "C" |
|
|
if score >= 40: return "D" |
|
|
return "E" |
|
|
|
|
|
def credit_decision(extract: FinancialExtract, ratios: Dict[str, Any], policies: Dict[str, Any]) -> Dict[str, Any]: |
|
|
latest = extract.latest() |
|
|
currency = extract.currency or "JPY" |
|
|
if not latest: |
|
|
return {"rating": "E", "risk_score": 0, "site_days": 0, |
|
|
"transaction_limit": 0.0, "transaction_limit_display": _fmt_currency(0.0, currency), |
|
|
"review_cycle": "取引前入金(与信不可)"} |
|
|
|
|
|
score = _risk_score(latest, ratios, policies) |
|
|
rating = _rating(score) |
|
|
|
|
|
site_map = {"A": 90, "B": 60, "C": 45, "D": 30, "E": 0} |
|
|
review_map = {"A": "年1回", "B": "半年毎", "C": "四半期毎", "D": "月次", "E": "取引ごと"} |
|
|
|
|
|
revenue = latest.revenue or 0.0 |
|
|
working_capital = (latest.current_assets or 0.0) - (latest.current_liabilities or 0.0) |
|
|
try: |
|
|
cr_val = float(ratios.get("current_ratio", "0").replace("—","0")) |
|
|
except Exception: |
|
|
cr_val = 0.0 |
|
|
liquidity_factor = 0.4 + 0.6 * (min(1.0, max(0.0, cr_val / 2.0))) |
|
|
risk_factor = 0.5 + 0.5 * (score / 100.0) |
|
|
|
|
|
site_days = site_map[rating] |
|
|
base_limit = (revenue / 12.0) * (site_days / 30.0) * 0.6 |
|
|
cap = max(0.0, working_capital) * 0.4 |
|
|
tx_limit = min(cap, base_limit * liquidity_factor * risk_factor) |
|
|
tx_limit = max(0.0, tx_limit) |
|
|
|
|
|
return {"rating": rating, "risk_score": round(score, 1), "site_days": site_days, |
|
|
"transaction_limit": tx_limit, "transaction_limit_display": _fmt_currency(tx_limit, currency), |
|
|
"review_cycle": review_map[rating]} |
|
|
|
|
|
def _annuity_factor(rate: float, years: int) -> float: |
|
|
r = rate / 100.0 |
|
|
n = max(1, years) |
|
|
if r <= 0: return 1.0 / n |
|
|
return r / (1 - (1 + r) ** (-n)) |
|
|
|
|
|
def loan_decision(extract: FinancialExtract, ratios: Dict[str, Any], base_rate_pct: float, policies: Dict[str, Any]) -> Dict[str, Any]: |
|
|
latest = extract.latest() |
|
|
currency = extract.currency or "JPY" |
|
|
if not latest: |
|
|
return {"max_principal": 0.0, "max_principal_display": _fmt_currency(0.0, currency), |
|
|
"term_years": 0, "interest_rate_pct": base_rate_pct, "target_dscr": 0.0} |
|
|
|
|
|
score = _risk_score(latest, ratios, policies) |
|
|
rating = _rating(score) |
|
|
|
|
|
rp = {"A": 0.5, "B": 1.0, "C": 2.0, "D": 3.5, "E": 0.0}[rating] |
|
|
if rating == "E": |
|
|
return {"max_principal": 0.0, "max_principal_display": _fmt_currency(0.0, currency), |
|
|
"term_years": 0, "interest_rate_pct": 0.0, "target_dscr": 0.0} |
|
|
|
|
|
interest_rate_pct = base_rate_pct + rp |
|
|
term_years = {"A": 5, "B": 4, "C": 3, "D": 1}[rating] |
|
|
target_dscr = {"A": 1.6, "B": 1.4, "C": 1.3, "D": 1.2}[rating] |
|
|
|
|
|
ebitda = ratios.get("_latest_ebitda") |
|
|
if not ebitda or ebitda <= 0: |
|
|
return {"max_principal": 0.0, "max_principal_display": _fmt_currency(0.0, currency), |
|
|
"term_years": term_years, "interest_rate_pct": interest_rate_pct, "target_dscr": target_dscr} |
|
|
|
|
|
ann = _annuity_factor(interest_rate_pct, term_years) |
|
|
annual_debt_service_cap = ebitda / target_dscr |
|
|
max_principal = max(0.0, annual_debt_service_cap / ann) |
|
|
|
|
|
ta = latest.total_assets or 0.0 |
|
|
cash = latest.cash_and_equivalents or 0.0 |
|
|
nta_cap = max(0.0, (ta - cash) * 0.5) |
|
|
max_principal = min(max_principal, nta_cap) |
|
|
|
|
|
return {"max_principal": max_principal, "max_principal_display": _fmt_currency(max_principal, currency), |
|
|
"term_years": term_years, "interest_rate_pct": round(interest_rate_pct, 2), "target_dscr": target_dscr} |
|
|
|
|
|
def _market_factor_from_score(score: int) -> Tuple[float, List[float]]: |
|
|
|
|
|
table = { |
|
|
1: (0.85, [0.75, 0.90, 1.05]), |
|
|
2: (0.93, [0.85, 0.95, 1.10]), |
|
|
3: (1.00, [0.90, 1.00, 1.15]), |
|
|
4: (1.07, [0.95, 1.05, 1.25]), |
|
|
5: (1.15, [1.00, 1.10, 1.35]), |
|
|
} |
|
|
return table.get(int(score) if score else 3, (1.0, [0.9, 1.0, 1.15])) |
|
|
|
|
|
def investment_decision( |
|
|
extract: FinancialExtract, |
|
|
ratios: Dict[str, Any], |
|
|
policies: Dict[str, Any], |
|
|
multiples: Optional[MultipleSuggestion], |
|
|
market_outlook: Optional[MarketOutlook] = None, |
|
|
) -> Dict[str, Any]: |
|
|
latest = extract.latest() |
|
|
currency = extract.currency or "JPY" |
|
|
if not latest: |
|
|
return {"ev": 0.0, "ev_display": _fmt_currency(0.0, currency), |
|
|
"market_cap": 0.0, "market_cap_display": _fmt_currency(0.0, currency), |
|
|
"recommended_check_size": 0.0, "recommended_check_size_display": _fmt_currency(0.0, currency), |
|
|
"attractiveness": 1, "growth_label": "Low"} |
|
|
|
|
|
g = ratios.get("_revenue_growth_float") |
|
|
if g is None: glabel = "Unknown" |
|
|
elif g >= 0.25: glabel = "High" |
|
|
elif g >= 0.05: glabel = "Medium" |
|
|
else: glabel = "Low" |
|
|
|
|
|
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) |
|
|
revenue = latest.revenue or 0.0 |
|
|
total_debt = latest.total_debt or 0.0 |
|
|
cash = latest.cash_and_equivalents or 0.0 |
|
|
net_debt = max(0.0, total_debt - cash) |
|
|
|
|
|
if multiples and ebitda and ebitda > 0: |
|
|
ev = ebitda * multiples.ebitda_multiple |
|
|
elif ebitda and ebitda > 0: |
|
|
ev = ebitda * 8.0 |
|
|
else: |
|
|
rev_mult = multiples.revenue_multiple if multiples else 1.5 |
|
|
ev = revenue * rev_mult |
|
|
|
|
|
market_cap = max(0.0, ev - net_debt) |
|
|
|
|
|
score = _risk_score(latest, ratios, policies) |
|
|
rating = _rating(score) |
|
|
pct = {"A": 0.15, "B": 0.12, "C": 0.08, "D": 0.04, "E": 0.0}[rating] |
|
|
base_check = market_cap * pct |
|
|
|
|
|
attractiveness = 1 |
|
|
if rating == "A": attractiveness = 5 if glabel == "High" else 4 |
|
|
elif rating == "B": attractiveness = 4 if glabel != "Low" else 3 |
|
|
elif rating == "C": attractiveness = 3 if glabel == "High" else 2 |
|
|
elif rating == "D": attractiveness = 1 |
|
|
|
|
|
|
|
|
market_factor, scenarios = (1.0, [0.9, 1.0, 1.15]) |
|
|
if market_outlook: |
|
|
market_factor, scenarios = _market_factor_from_score(market_outlook.expectation_score) |
|
|
if market_outlook.expectation_score >= 4: |
|
|
attractiveness = min(5, attractiveness + 1) |
|
|
elif market_outlook.expectation_score <= 2: |
|
|
attractiveness = max(1, attractiveness - 1) |
|
|
|
|
|
check = base_check * market_factor |
|
|
|
|
|
|
|
|
scenario_rows = [] |
|
|
for label, fac in zip(["保守", "基準", "強気"], scenarios): |
|
|
scenario_rows.append({ |
|
|
"label": label, |
|
|
"factor": fac, |
|
|
"check_size": base_check * fac, |
|
|
"check_size_display": _fmt_currency(base_check * fac, currency), |
|
|
}) |
|
|
|
|
|
return { |
|
|
"ev": ev, "ev_display": _fmt_currency(ev, currency), |
|
|
"market_cap": market_cap, "market_cap_display": _fmt_currency(market_cap, currency), |
|
|
"recommended_check_size": check, "recommended_check_size_display": _fmt_currency(check, currency), |
|
|
"attractiveness": attractiveness, "growth_label": glabel, |
|
|
"market_factor": round(market_factor, 2), |
|
|
"scenarios": scenario_rows, |
|
|
} |
|
|
|
|
|
def build_report_dict( |
|
|
extract: FinancialExtract, |
|
|
ratios: Dict[str, Any], |
|
|
decisions: Dict[str, Any], |
|
|
unit_info: Optional[Dict[str, Any]] = None, |
|
|
market_outlook: Optional[MarketOutlook] = None, |
|
|
) -> Dict[str, Any]: |
|
|
out = { |
|
|
"metadata": { |
|
|
"company_name": extract.company_name, |
|
|
"industry": extract.industry, |
|
|
"currency": extract.currency, |
|
|
"fiscal_year_end": extract.fiscal_year_end |
|
|
}, |
|
|
"extracted": extract.dict(), |
|
|
"ratios": ratios, |
|
|
"decisions": decisions, |
|
|
"disclaimer": "本ツールはAIによる推定・一般的な計算式に基づく参考提案であり、投資勧誘・融資約定・与信保証を目的としたものではありません。最終判断は自己責任で、必要に応じて専門家の確認を行ってください。", |
|
|
} |
|
|
if unit_info: out["unit_detection"] = unit_info |
|
|
if market_outlook: out["market_outlook"] = market_outlook.model_dump() |
|
|
return out |
|
|
|