
Kanchi Dividend Review Monitor
- 723 installs
- 2.6k repo stars
- Updated August 4, 2026
- tradermonty/claude-trading-skills
kanchi-dividend-review-monitor is an agent skill that scans dividend portfolios for Kanchi-style T1-T5 risk triggers and routes anomalies into OK, WARN, or REVIEW states without auto-selling for developers monitoring div
About
kanchi-dividend-review-monitor is a claude-trading-skills module for dividend holdings surveillance using Kanchi-style forced-review triggers labeled T1 through T5. It treats automation as anomaly detection only—flagging dividend cuts, governance signals, and 8-K keyword hits—then classifies positions into OK, WARN, or REVIEW without executing sells. The skill supports daily, weekly, or quarterly cadences and responds to Japanese and English prompts around 減配検知, 8-K governance monitoring, and REVIEW queue automation. Developers reach for it when maintaining dividend income portfolios that need systematic escalation to human review rather than blind algo trading. It outputs structured review queues, not brokerage orders.
- Implements Kanchi-style T1-T5 forced-review triggers
- Converts anomalies into deterministic OK/WARN/REVIEW states
- Performs 8-K/governance keyword scans tied to portfolio tickers
- Never auto-sells; always produces evidence for human confirmation
- Supports daily, weekly, and quarterly anomaly detection runs
Kanchi Dividend Review Monitor by the numbers
- 723 all-time installs (skills.sh)
- +33 installs in the week ending Jul 27, 2026 (Skillselion tracking)
- Ranked #187 of 1,106 Finance & Trading skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/tradermonty/claude-trading-skills --skill kanchi-dividend-review-monitorAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 723 |
|---|---|
| repo stars | ★ 2.6k |
| Last updated | August 4, 2026 |
| Repository | tradermonty/claude-trading-skills ↗ |
How do you monitor dividend cut risk signals?
Automatically scan dividend portfolios for risk signals and route anomalies into a forced human review queue.
Who is it for?
Developers or quants operating dividend income portfolios who need scheduled anomaly detection with mandatory human review gates.
Skip if: Traders seeking automated sell execution, intraday scalping signals, or non-dividend equity strategies.
When should I use this skill?
User asks for dividend risk monitoring, T1-T5 review triggers, 減配検知, 8-K governance scans, or REVIEW queue automation.
What you get
Portfolio positions classified as OK, WARN, or REVIEW with T1-T5 trigger annotations and a forced human review queue.
- OK/WARN/REVIEW classification report
- Forced human REVIEW queue entries
By the numbers
- Uses 5 Kanchi-style forced-review risk triggers labeled T1 through T5
- Classifies portfolio state into 3 outcomes: OK, WARN, and REVIEW
Files
Kanchi Dividend Review Monitor
Overview
Detect abnormal dividend-risk signals and route them into a human review queue. Treat automation as anomaly detection, not automated trade execution.
When to Use
Use this skill when the user needs:
- Daily/weekly/quarterly anomaly detection for dividend holdings.
- Forced review queueing for T1-T5 risk triggers.
- 8-K/governance keyword scans tied to portfolio tickers.
- Deterministic
OK/WARN/REVIEWoutput before manual decision making.
Prerequisites
Provide normalized input JSON that follows:
references/input-schema.md
If upstream data is unavailable, provide at least:
tickerinstrument_typedividend.latest_regulardividend.prior_regular
Non-Negotiable Rule
Never auto-sell based only on machine triggers. Always create WARN or REVIEW evidence for human confirmation first.
State Machine
OK: no action.WARN: add to next check cycle and pause optional adds.REVIEW: immediate human review ticket + pause adds.
Use references/trigger-matrix.md for trigger thresholds and actions.
Flat-dividend cadence caveat
When T6 is driven only by freeze_flag / latest regular dividend equal to prior regular dividend, treat it as a WARN for cadence confirmation, not as proof of dividend deterioration. Many quarterly dividend payers repeat the same dividend for several quarters between annual raise cycles. In reports, phrase this as “confirm next dividend-growth cadence / pause optional adds until checked” and avoid implying a cut or broken thesis unless T1/T2/T3/T4/T5 evidence also supports escalation.
Monitoring Cadence
- Daily:
- T1 dividend cut/suspension.
- T4 SEC filing keyword scan (8-K oriented).
- Weekly:
- T3 proxy credit stress checks.
- Quarterly:
- T2 coverage deterioration and T5 structural decline scoring.
Workflow
1) Normalize input dataset
Collect per ticker fields in one JSON document:
- Dividend points (latest regular, prior regular, missing/zero flag).
- Coverage fields (FCF or FFO or NII, dividends paid, ratio history).
- Balance-sheet trend fields (net debt, interest coverage, buybacks/dividends).
- Filing text snippets (especially recent 8-K or equivalent alert text).
- Operations trend fields (revenue CAGR, margin trend, guidance trend).
Use references/input-schema.md for field definitions and sample payload.
2) Run the rule engine
Run:
python3 skills/kanchi-dividend-review-monitor/scripts/build_review_queue.py \
--input /path/to/monitor_input.json \
--output-dir reports/The script maps each ticker to OK/WARN/REVIEW based on T1-T5. Output files are saved to the specified directory with dated filenames (e.g., review_queue_20260227.json and .md).
3) Prioritize and deduplicate
If multiple triggers fire:
- Keep all findings for audit trail.
- Escalate final state to highest severity only.
- Store trigger reasons as single-line evidence.
4) Generate human review tickets
For each REVIEW ticker, include:
- Trigger IDs and evidence.
- Suspected failure mode.
- Required manual checks for next decision.
Use references/review-ticket-template.md output format.
SEC Filing Guardrail
When implementing live SEC fetchers:
- Include a compliant
User-Agentstring (name + email). - Use caching and throttling.
- Respect SEC fair-access guidance.
Output Contract
Always return: 1. Queue JSON with summary counts and ticker-level findings. 2. Markdown dashboard for quick triage. 3. List of immediate REVIEW tickets.
Multi-Skill Handoff
- Consume ticker universe and baseline assumptions from
kanchi-dividend-sop. - Feed
REVIEWresults back tokanchi-dividend-sopfor re-underwriting and position-size review. - Share account-type context with
kanchi-dividend-us-tax-accountingwhen risk events imply account relocation decisions.
Resources
scripts/build_review_queue.py: local rule engine for T1-T5.scripts/tests/test_build_review_queue.py: unit tests for T1-T5 and report rendering.references/trigger-matrix.md: trigger definitions, cadence, and actions.references/input-schema.md: normalized input schema and sample JSON.references/review-ticket-template.md: standardized manual-review ticket layout.
interface:
display_name: "Kanchi Dividend Review Monitor"
short_description: "Help with Kanchi Dividend Review Monitor tasks"
Input Schema
Use this normalized JSON schema for build_review_queue.py.
Required Top-Level Fields
as_of: ISO date string.holdings: array of ticker records.
Optional Top-Level Fields
schema_version: integer emitted bykanchi-dividend-sop
build_entry_signals.py (current: 2). Consumers MUST tolerate unknown future versions and unknown fields rather than failing (MJ-10).
Holding Object
{
"ticker": "ABC",
"instrument_type": "stock",
"dividend": {
"latest_regular": 0.50,
"prior_regular": 0.52,
"is_missing": false,
"flags": {
"cut_flag": false,
"freeze_flag": false,
"special_dividend_flag": false,
"variable_policy_flag": false
}
},
"cashflow": {
"fcf": 1200.0,
"ffo": null,
"nii": null,
"dividends_paid": 900.0,
"coverage_ratio_history": [0.72, 0.85]
},
"balance_sheet": {
"net_debt_history": [3000.0, 3400.0, 3800.0],
"interest_coverage_history": [4.6, 3.8, 2.9]
},
"capital_returns": {
"buybacks": 250.0,
"dividends_paid": 900.0,
"fcf": 1200.0
},
"filings": {
"recent_text": "8-K filed. No restatement language.",
"latest_8k_text": "Item 4.02 non-reliance ...",
"headlines": ["Company files 8-K", "Audit committee review announced"]
},
"operations": {
"revenue_cagr_5y": -1.2,
"margin_trend": "down",
"guidance_trend": "down",
"dividend_growth_stalled": true
}
}Minimal Viable Input
If upstream data is partial, include at least:
tickerinstrument_typedividend.latest_regulardividend.prior_regular
The rule engine will still run and skip unavailable triggers.
Review Ticket Template
Use this template for each REVIEW ticker.
# REVIEW Ticket: [Ticker]
## Trigger Summary
- Final status: REVIEW
- Trigger hits: [T1,T4]
- Generated at:
## Evidence (One Line Each)
- T1:
- T2:
- T3:
- T4:
- T5:
## Manual Checks Required
1. Read latest filing/release primary source text.
2. Confirm scope and duration of the issue.
3. Assess impact on dividend continuity.
4. Recalculate payout/coverage assumptions.
5. Decide portfolio action (hold / trim / exit review).
## Decision Log
- Decision:
- Rationale:
- Owner:
- Due date:Trigger Matrix (T1-T5)
Apply these rules to route tickers into OK, WARN, or REVIEW.
Severity Policy
OK: no forced action.WARN: queue for next checkpoint and pause optional adds.REVIEW: immediate human review ticket and pause adds.
Trigger Definitions
| Trigger | Core signal | Default machine rule | Frequency | Default action |
|---|---|---|---|---|
| T1 | Dividend cut or suspension | latest_regular < prior_regular * 0.99 OR latest_regular <= 0 OR missing dividend feed | Daily | REVIEW |
| T2 | Coverage deterioration | denominator <= 0 with positive dividends OR coverage ratio >1.0 for 2 periods | Quarterly | WARN/REVIEW |
| T3 | Credit stress proxy | Net debt rising 3 periods + weakening interest coverage and/or stretched capital return | Weekly + Quarterly confirm | WARN/REVIEW |
| T4 | Governance/accounting red flag | Filing text hits Item 4.02, non-reliance, restatement, material weakness, SEC investigation | Daily | REVIEW |
| T5 | Structural decline | 2+ simultaneous negatives: revenue CAGR <0, margin downtrend, guidance downtrend, stalled dividend growth | Quarterly | WARN/REVIEW |
| T6 | Dividend-policy change (WS-1 basis flags) | cut_flag OR variable_policy_flag → REVIEW; freeze_flag OR special_dividend_flag → WARN | Daily | WARN/REVIEW |
T6 consumes the WS-1 dividend_basis flags emitted by kanchi-dividend-sop/build_entry_signals.py (schema v2+). Flags are read from dividend.flags (preferred) or directly off dividend. A top-level schema_version and any unknown fields are ignored, so a newer upstream schema cannot silently break this monitor.
Denominator Mapping For T2
| Instrument | Denominator |
|---|---|
| Stock | FCF (CFO - CapEx) |
| REIT | FFO/AFFO |
| BDC | NII |
| ETF | Use holdings-level quality proxies if fund-level coverage is unavailable |
Escalation Rule
If multiple triggers fire, keep all findings and set final state to highest severity:
OK < WARN < REVIEW.
Within T2 itself, sustained breach (>1.0 for 2 periods) takes priority over single-period breach.
#!/usr/bin/env python3
"""
Build Kanchi-style dividend review queue from normalized monitoring input.
This script is intentionally data-source agnostic:
- Upstream jobs fetch dividend/filing/fundamental data.
- This script only applies deterministic T1-T5 rules.
"""
from __future__ import annotations
import argparse
import json
from dataclasses import asdict, dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
SEVERITY_RANK = {"OK": 0, "WARN": 1, "REVIEW": 2}
INSTRUMENT_DENOMINATOR = {
"stock": "fcf",
"etf": "fcf",
"reit": "ffo",
"bdc": "nii",
}
T4_KEYWORDS = [
"item 4.02",
"non-reliance",
"restatement",
"material weakness",
"sec investigation",
]
@dataclass
class TriggerFinding:
trigger: str
status: str
reason: str
evidence: dict[str, Any] = field(default_factory=dict)
def to_float(value: Any) -> float | None:
try:
if value is None:
return None
return float(value)
except (TypeError, ValueError):
return None
def float_history(raw: Any) -> list[float]:
if not isinstance(raw, list):
return []
out: list[float] = []
for item in raw:
value = to_float(item)
if value is not None:
out.append(value)
return out
def rank(status: str) -> int:
return SEVERITY_RANK.get(status, 0)
def t1_dividend_cut_or_suspension(holding: dict[str, Any]) -> TriggerFinding | None:
dividend = holding.get("dividend", {})
latest = to_float(dividend.get("latest_regular"))
prior = to_float(dividend.get("prior_regular"))
missing = bool(dividend.get("is_missing", False))
if missing:
return TriggerFinding(
trigger="T1",
status="REVIEW",
reason="Dividend data missing; suspension or data integrity issue possible.",
evidence={"latest_regular": latest, "prior_regular": prior, "is_missing": missing},
)
if latest is not None and latest <= 0:
return TriggerFinding(
trigger="T1",
status="REVIEW",
reason="Latest regular dividend is zero.",
evidence={"latest_regular": latest, "prior_regular": prior},
)
if latest is not None and prior is not None and prior > 0 and latest < prior * 0.99:
return TriggerFinding(
trigger="T1",
status="REVIEW",
reason="Latest regular dividend is lower than prior regular dividend by more than 1%.",
evidence={"latest_regular": latest, "prior_regular": prior},
)
return None
def t2_coverage_deterioration(holding: dict[str, Any]) -> TriggerFinding | None:
instrument = str(holding.get("instrument_type", "stock")).lower()
cashflow = holding.get("cashflow", {})
denominator_key = INSTRUMENT_DENOMINATOR.get(instrument, "fcf")
denominator = to_float(cashflow.get(denominator_key))
dividends_paid = to_float(cashflow.get("dividends_paid"))
ratio_history = float_history(cashflow.get("coverage_ratio_history"))
evidence = {
"instrument_type": instrument,
"denominator_key": denominator_key,
"denominator_value": denominator,
"dividends_paid": dividends_paid,
"coverage_ratio_history": ratio_history,
}
if denominator is not None and dividends_paid is not None:
if denominator <= 0 and dividends_paid > 0:
return TriggerFinding(
trigger="T2",
status="REVIEW",
reason="Distribution is positive while denominator is non-positive.",
evidence=evidence,
)
current_ratio: float | None = None
if denominator is not None and dividends_paid is not None and denominator > 0:
current_ratio = dividends_paid / denominator
evidence["current_ratio"] = round(current_ratio, 4)
if len(ratio_history) >= 2:
last = ratio_history[-1]
prev = ratio_history[-2]
if last > 1.0 and prev > 1.0:
return TriggerFinding(
trigger="T2",
status="REVIEW",
reason="Coverage ratio above 1.0 for two consecutive periods.",
evidence=evidence,
)
if current_ratio is not None and current_ratio > 1.0:
# If the history likely excludes current period, still escalate if prior period is also > 1.0.
if ratio_history and ratio_history[-1] > 1.0:
return TriggerFinding(
trigger="T2",
status="REVIEW",
reason="Coverage ratio above 1.0 for two consecutive periods.",
evidence=evidence,
)
# Single-period breach can be noisy; classify as WARN unless sustained.
return TriggerFinding(
trigger="T2",
status="WARN",
reason="Current period payout coverage ratio exceeds 1.0.",
evidence=evidence,
)
if len(ratio_history) >= 2:
last = ratio_history[-1]
prev = ratio_history[-2]
if last > 0.8 and last > prev:
return TriggerFinding(
trigger="T2",
status="WARN",
reason="Coverage ratio is above 0.8 and still rising.",
evidence=evidence,
)
return None
def strictly_increasing(values: list[float], lookback: int = 3) -> bool:
if len(values) < lookback:
return False
window = values[-lookback:]
return all(window[i] < window[i + 1] for i in range(len(window) - 1))
def t3_credit_stress_proxy(holding: dict[str, Any]) -> TriggerFinding | None:
balance_sheet = holding.get("balance_sheet", {})
capital = holding.get("capital_returns", {})
cashflow = holding.get("cashflow", {})
net_debt_history = float_history(balance_sheet.get("net_debt_history"))
interest_coverage_history = float_history(balance_sheet.get("interest_coverage_history"))
buybacks = to_float(capital.get("buybacks")) or 0.0
dividends_paid = (
to_float(capital.get("dividends_paid")) or to_float(cashflow.get("dividends_paid")) or 0.0
)
fcf = to_float(capital.get("fcf"))
if fcf is None:
fcf = to_float(cashflow.get("fcf"))
debt_up = strictly_increasing(net_debt_history, lookback=3)
icov_down = len(interest_coverage_history) >= 2 and (
interest_coverage_history[-1] < interest_coverage_history[-2]
)
icov_weak = len(interest_coverage_history) >= 1 and interest_coverage_history[-1] < 2.5
capital_stretch = False
if fcf is not None and fcf > 0:
capital_stretch = (buybacks + dividends_paid) > (fcf * 1.10)
evidence = {
"net_debt_history": net_debt_history,
"interest_coverage_history": interest_coverage_history,
"buybacks": buybacks,
"dividends_paid": dividends_paid,
"fcf": fcf,
"debt_up_3p": debt_up,
"icov_down": icov_down,
"icov_weak": icov_weak,
"capital_stretch": capital_stretch,
}
if debt_up and (icov_down or icov_weak or capital_stretch):
return TriggerFinding(
trigger="T3",
status="REVIEW",
reason="Debt trend worsens with weakening coverage or aggressive capital returns.",
evidence=evidence,
)
if debt_up or (icov_down and icov_weak):
return TriggerFinding(
trigger="T3",
status="WARN",
reason="Early proxy credit stress detected.",
evidence=evidence,
)
return None
def t4_governance_or_filing_alert(holding: dict[str, Any]) -> TriggerFinding | None:
filings = holding.get("filings", {})
text_parts: list[str] = []
for key in ("recent_text", "latest_8k_text", "headlines"):
value = filings.get(key)
if isinstance(value, str):
text_parts.append(value)
elif isinstance(value, list):
text_parts.extend(str(item) for item in value)
full_text = " ".join(text_parts).lower()
if not full_text:
return None
hits = [kw for kw in T4_KEYWORDS if kw in full_text]
if hits:
return TriggerFinding(
trigger="T4",
status="REVIEW",
reason="Potential governance/accounting red-flag keywords found in filings text.",
evidence={"keyword_hits": hits},
)
return None
def t5_structural_decline(holding: dict[str, Any]) -> TriggerFinding | None:
ops = holding.get("operations", {})
revenue_cagr_5y = to_float(ops.get("revenue_cagr_5y"))
margin_trend = str(ops.get("margin_trend", "")).lower()
guidance_trend = str(ops.get("guidance_trend", "")).lower()
dividend_growth_stalled = bool(ops.get("dividend_growth_stalled", False))
score = 0
reasons: list[str] = []
if revenue_cagr_5y is not None and revenue_cagr_5y < 0:
score += 1
reasons.append("revenue_cagr_5y<0")
if margin_trend == "down":
score += 1
reasons.append("margin_trend=down")
if guidance_trend == "down":
score += 1
reasons.append("guidance_trend=down")
if dividend_growth_stalled:
score += 1
reasons.append("dividend_growth_stalled=true")
evidence = {
"score": score,
"reasons": reasons,
"revenue_cagr_5y": revenue_cagr_5y,
"margin_trend": margin_trend,
"guidance_trend": guidance_trend,
"dividend_growth_stalled": dividend_growth_stalled,
}
if score >= 3:
return TriggerFinding(
trigger="T5",
status="REVIEW",
reason="Multiple structural decline factors are active.",
evidence=evidence,
)
if score >= 2:
return TriggerFinding(
trigger="T5",
status="WARN",
reason="Structural decline score indicates rising business-model risk.",
evidence=evidence,
)
return None
def t6_dividend_policy_change(holding: dict[str, Any]) -> TriggerFinding | None:
"""WS-8 slice: consume WS-1 dividend-basis flags.
Accepts flags from `dividend.flags` (preferred) or, as a convenience,
directly on the `dividend` object. Unknown extra keys (incl. a
top-level `schema_version`) are ignored, so a newer build_entry_signals
schema cannot silently break this monitor (improvement-plan MJ-10).
"""
dividend = holding.get("dividend", {})
flags = dividend.get("flags")
if not isinstance(flags, dict):
flags = dividend
cut = bool(flags.get("cut_flag", False))
variable = bool(flags.get("variable_policy_flag", False))
freeze = bool(flags.get("freeze_flag", False))
special = bool(flags.get("special_dividend_flag", False))
if not (cut or variable or freeze or special):
return None
evidence = {
"cut_flag": cut,
"variable_policy_flag": variable,
"freeze_flag": freeze,
"special_dividend_flag": special,
"schema_version": holding.get("schema_version"),
}
if cut:
return TriggerFinding(
trigger="T6",
status="REVIEW",
reason="WS-1 cut_flag set: regular dividend rate declined year-over-year.",
evidence=evidence,
)
if variable:
return TriggerFinding(
trigger="T6",
status="REVIEW",
reason="WS-1 variable_policy_flag set: payout is a variable % of profit; "
"not a reliable income base.",
evidence=evidence,
)
if freeze:
return TriggerFinding(
trigger="T6",
status="WARN",
reason="WS-1 freeze_flag set: dividend held flat (policy change); "
"pause optional adds and re-underwrite growth thesis.",
evidence=evidence,
)
return TriggerFinding(
trigger="T6",
status="WARN",
reason="WS-1 special_dividend_flag set: headline/TTM yield inflated by a "
"special; verify the regular-only forward yield before any add.",
evidence=evidence,
)
def evaluate_holding(holding: dict[str, Any]) -> dict[str, Any]:
ticker = str(holding.get("ticker", "")).upper().strip()
instrument_type = str(holding.get("instrument_type", "stock")).lower()
findings: list[TriggerFinding] = []
for evaluator in (
t1_dividend_cut_or_suspension,
t2_coverage_deterioration,
t3_credit_stress_proxy,
t4_governance_or_filing_alert,
t5_structural_decline,
t6_dividend_policy_change,
):
finding = evaluator(holding)
if finding:
findings.append(finding)
final_status = "OK"
for finding in findings:
if rank(finding.status) > rank(final_status):
final_status = finding.status
actions: list[str]
if final_status == "REVIEW":
actions = [
"pause_buy_adds",
"create_review_ticket",
"human_read_full_disclosure",
]
elif final_status == "WARN":
actions = [
"pause_buy_adds_optional",
"append_next_earnings_checklist",
]
else:
actions = []
return {
"ticker": ticker,
"instrument_type": instrument_type,
"status": final_status,
"actions": actions,
"findings": [asdict(f) for f in findings],
}
def render_markdown(report: dict[str, Any]) -> str:
as_of = report.get("as_of", "unknown")
lines = [
f"# Dividend Review Queue (as_of: {as_of})",
"",
f"- Generated at: `{report['generated_at']}`",
f"- As of: `{report['as_of']}`",
"",
"## Summary",
"",
f"- OK: `{report['summary']['OK']}`",
f"- WARN: `{report['summary']['WARN']}`",
f"- REVIEW: `{report['summary']['REVIEW']}`",
"",
"## Queue",
"",
"| Ticker | Status | Triggers | Actions |",
"|---|---|---|---|",
]
for item in report["results"]:
triggers = ",".join(f["trigger"] for f in item["findings"]) or "-"
actions = ",".join(item["actions"]) or "-"
lines.append(f"| {item['ticker']} | {item['status']} | {triggers} | {actions} |")
lines.append("")
lines.append("## Findings")
lines.append("")
for item in report["results"]:
if not item["findings"]:
continue
lines.append(f"### {item['ticker']} ({item['status']})")
for finding in item["findings"]:
lines.append(f"- `{finding['trigger']}` `{finding['status']}`: {finding['reason']}")
lines.append("")
return "\n".join(lines)
def build_report(payload: dict[str, Any]) -> dict[str, Any]:
as_of = payload.get("as_of")
if not as_of:
as_of = datetime.now(timezone.utc).date().isoformat()
holdings = payload.get("holdings", [])
if not isinstance(holdings, list):
raise ValueError("Input JSON field 'holdings' must be a list.")
results = [evaluate_holding(holding) for holding in holdings]
summary = {"OK": 0, "WARN": 0, "REVIEW": 0}
for row in results:
summary[row["status"]] += 1
return {
"generated_at": datetime.now(timezone.utc).isoformat(),
"as_of": as_of,
"summary": summary,
"results": results,
}
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Build Kanchi dividend review queue.")
parser.add_argument("--input", required=True, help="Path to normalized input JSON.")
parser.add_argument("--output", help="Path to output JSON report (overrides --output-dir).")
parser.add_argument(
"--markdown",
help="Path to markdown report (overrides --output-dir).",
)
parser.add_argument(
"--output-dir",
default="reports",
help="Directory for output files (default: reports). Creates dated filenames.",
)
return parser.parse_args()
def main() -> int:
args = parse_args()
input_path = Path(args.input)
payload = json.loads(input_path.read_text())
report = build_report(payload)
as_of = report.get("as_of", datetime.now(timezone.utc).date().isoformat())
date_suffix = as_of.replace("-", "")
output_dir = Path(args.output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
if args.output:
output_path = Path(args.output)
else:
output_path = output_dir / f"review_queue_{date_suffix}.json"
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_text(json.dumps(report, indent=2, ensure_ascii=True) + "\n")
if args.markdown:
markdown_path = Path(args.markdown)
else:
markdown_path = output_dir / f"review_queue_{date_suffix}.md"
markdown_path.parent.mkdir(parents=True, exist_ok=True)
markdown_path.write_text(render_markdown(report) + "\n")
print(f"Wrote JSON report: {output_path}")
print(f"Wrote markdown report: {markdown_path}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
"""Shared fixtures for kanchi-dividend-review-monitor tests."""
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
"""Unit tests for build_review_queue.py trigger logic."""
from build_review_queue import (
build_report,
evaluate_holding,
render_markdown,
t1_dividend_cut_or_suspension,
t2_coverage_deterioration,
t3_credit_stress_proxy,
t4_governance_or_filing_alert,
t5_structural_decline,
t6_dividend_policy_change,
)
def test_t6_variable_policy_flag_reviews() -> None:
finding = t6_dividend_policy_change(
{"ticker": "CALM", "dividend": {"flags": {"variable_policy_flag": True}}}
)
assert finding is not None
assert finding.trigger == "T6" and finding.status == "REVIEW"
def test_t6_freeze_flag_warns_via_inline_flags() -> None:
# Flags read directly off `dividend` (convenience path) also work.
finding = t6_dividend_policy_change({"ticker": "CMCSA", "dividend": {"freeze_flag": True}})
assert finding is not None
assert finding.trigger == "T6" and finding.status == "WARN"
def test_t6_special_flag_warns() -> None:
finding = t6_dividend_policy_change(
{"ticker": "ORI", "dividend": {"flags": {"special_dividend_flag": True}}}
)
assert finding is not None and finding.status == "WARN"
def test_t6_none_when_no_flags() -> None:
assert t6_dividend_policy_change({"ticker": "JNJ", "dividend": {}}) is None
def test_unknown_schema_version_is_tolerated() -> None:
# A newer upstream schema_version + unknown fields must not break eval.
holding = {
"ticker": "CALM",
"schema_version": 999,
"future_field": {"x": 1},
"dividend": {"flags": {"variable_policy_flag": True}, "new_subfield": 42},
}
result = evaluate_holding(holding)
assert result["status"] == "REVIEW"
def test_t1_detects_dividend_cut() -> None:
holding = {
"ticker": "AAA",
"dividend": {"latest_regular": 0.20, "prior_regular": 0.25, "is_missing": False},
}
finding = t1_dividend_cut_or_suspension(holding)
assert finding is not None
assert finding.status == "REVIEW"
assert finding.trigger == "T1"
def test_t1_detects_missing_dividend_data() -> None:
holding = {
"ticker": "BBB",
"dividend": {"latest_regular": None, "prior_regular": 0.50, "is_missing": True},
}
finding = t1_dividend_cut_or_suspension(holding)
assert finding is not None
assert finding.status == "REVIEW"
assert finding.trigger == "T1"
assert "missing" in finding.reason.lower()
def test_t2_returns_review_when_history_shows_two_consecutive_breaches() -> None:
holding = {
"instrument_type": "stock",
"cashflow": {
"fcf": 100.0,
"dividends_paid": 130.0,
"coverage_ratio_history": [1.05, 1.10],
},
}
finding = t2_coverage_deterioration(holding)
assert finding is not None
assert finding.status == "REVIEW"
assert finding.trigger == "T2"
def test_t2_returns_warn_for_single_period_breach_only() -> None:
holding = {
"instrument_type": "stock",
"cashflow": {
"fcf": 100.0,
"dividends_paid": 110.0,
"coverage_ratio_history": [0.75, 0.90],
},
}
finding = t2_coverage_deterioration(holding)
assert finding is not None
assert finding.status == "WARN"
assert finding.trigger == "T2"
def test_t2_returns_review_when_denominator_non_positive_with_distribution() -> None:
holding = {
"instrument_type": "stock",
"cashflow": {
"fcf": -5.0,
"dividends_paid": 120.0,
"coverage_ratio_history": [],
},
}
finding = t2_coverage_deterioration(holding)
assert finding is not None
assert finding.status == "REVIEW"
def test_t2_warns_when_ratio_above_point8_and_rising() -> None:
holding = {
"instrument_type": "stock",
"cashflow": {
"fcf": 100.0,
"dividends_paid": 80.0,
"coverage_ratio_history": [0.70, 0.85],
},
}
finding = t2_coverage_deterioration(holding)
assert finding is not None
assert finding.status == "WARN"
def test_t3_returns_review_on_debt_up_and_coverage_weakening() -> None:
holding = {
"balance_sheet": {
"net_debt_history": [1000, 1200, 1400],
"interest_coverage_history": [4.0, 3.0, 2.3],
},
"capital_returns": {"buybacks": 10.0, "dividends_paid": 60.0, "fcf": 100.0},
}
finding = t3_credit_stress_proxy(holding)
assert finding is not None
assert finding.status == "REVIEW"
assert finding.trigger == "T3"
def test_t3_returns_warn_on_debt_up_only() -> None:
holding = {
"balance_sheet": {
"net_debt_history": [1000, 1200, 1400],
"interest_coverage_history": [6.0, 6.1, 6.2],
},
"capital_returns": {"buybacks": 10.0, "dividends_paid": 60.0, "fcf": 1000.0},
}
finding = t3_credit_stress_proxy(holding)
assert finding is not None
assert finding.status == "WARN"
assert finding.trigger == "T3"
def test_t4_detects_item_402_keyword() -> None:
holding = {
"filings": {"recent_text": "The company filed 8-K Item 4.02 non-reliance disclosure."}
}
finding = t4_governance_or_filing_alert(holding)
assert finding is not None
assert finding.status == "REVIEW"
assert finding.trigger == "T4"
def test_t5_warns_on_two_structural_decline_signals() -> None:
holding = {
"operations": {
"revenue_cagr_5y": -1.0,
"margin_trend": "down",
"guidance_trend": "flat",
"dividend_growth_stalled": False,
}
}
finding = t5_structural_decline(holding)
assert finding is not None
assert finding.status == "WARN"
assert finding.trigger == "T5"
def test_t5_returns_review_on_three_or_more_signals() -> None:
holding = {
"operations": {
"revenue_cagr_5y": -2.5,
"margin_trend": "down",
"guidance_trend": "down",
"dividend_growth_stalled": False,
}
}
finding = t5_structural_decline(holding)
assert finding is not None
assert finding.status == "REVIEW"
assert finding.trigger == "T5"
assert finding.evidence["score"] >= 3
def test_render_markdown_title_includes_as_of_date() -> None:
report = {
"generated_at": "2026-02-22T00:00:00+00:00",
"as_of": "2026-02-22",
"summary": {"OK": 1, "WARN": 0, "REVIEW": 0},
"results": [
{"ticker": "AAA", "status": "OK", "actions": [], "findings": []},
],
}
markdown = render_markdown(report)
assert markdown.splitlines()[0] == "# Dividend Review Queue (as_of: 2026-02-22)"
def test_build_report_counts_states() -> None:
payload = {
"as_of": "2026-02-22",
"holdings": [
{
"ticker": "AAA",
"instrument_type": "stock",
"dividend": {"latest_regular": 0.1, "prior_regular": 0.2, "is_missing": False},
},
{
"ticker": "BBB",
"instrument_type": "stock",
"dividend": {"latest_regular": 0.2, "prior_regular": 0.2, "is_missing": False},
},
],
}
report = build_report(payload)
assert report["summary"]["REVIEW"] == 1
assert report["summary"]["OK"] == 1
def test_build_report_handles_empty_holdings() -> None:
payload = {
"as_of": "2026-02-22",
"holdings": [],
}
report = build_report(payload)
assert report["summary"]["OK"] == 0
assert report["summary"]["WARN"] == 0
assert report["summary"]["REVIEW"] == 0
assert report["results"] == []
def test_main_with_output_dir(tmp_path) -> None:
import json
import subprocess
import sys
from pathlib import Path
input_data = {
"as_of": "2026-02-27",
"holdings": [
{
"ticker": "TEST",
"instrument_type": "stock",
"dividend": {"latest_regular": 0.5, "prior_regular": 0.5, "is_missing": False},
},
],
}
input_file = tmp_path / "input.json"
input_file.write_text(json.dumps(input_data))
output_dir = tmp_path / "output"
script_path = Path(__file__).parent.parent / "build_review_queue.py"
result = subprocess.run(
[
sys.executable,
str(script_path),
"--input",
str(input_file),
"--output-dir",
str(output_dir),
],
capture_output=True,
text=True,
)
assert result.returncode == 0, f"Script failed: {result.stderr}"
assert output_dir.exists()
json_files = list(output_dir.glob("review_queue_*.json"))
md_files = list(output_dir.glob("review_queue_*.md"))
assert len(json_files) == 1, f"Expected 1 JSON file, found {len(json_files)}"
assert len(md_files) == 1, f"Expected 1 MD file, found {len(md_files)}"
report = json.loads(json_files[0].read_text())
assert report["summary"]["OK"] == 1
Related skills
FAQ
Does kanchi-dividend-review-monitor execute trades automatically?
kanchi-dividend-review-monitor performs anomaly detection only. It assigns OK, WARN, or REVIEW states and enqueues human review—it does not auto-sell positions when dividend risk triggers fire.
What are T1-T5 triggers in kanchi-dividend-review-monitor?
kanchi-dividend-review-monitor uses five Kanchi-style forced-review triggers (T1-T5) to flag dividend cuts, governance issues, and related anomalies that require escalation beyond a simple OK status.