ChozhanMurugan
Initial deployment
0d7b049
Raw
History Blame Contribute Delete
5.71 kB
"""Command-line entry point. Run ``python -m eca.cli --help``."""
from __future__ import annotations
from collections.abc import Iterator
from pathlib import Path
import pandas as pd
import typer
from eca.config import settings
from eca.features import build_features_df
from eca.ingest import Transcript, load_hf_earnings_calls
from eca.utils import logger
app = typer.Typer(add_completion=False, no_args_is_help=True, help="Earnings Call Analyzer")
# ----- ingest + features -----
@app.command("build-dataset")
def build_dataset(
source: str = typer.Option("hf", help="hf | edgar | motley_fool"),
tickers: str = typer.Option("", help="comma-separated tickers (edgar/motley_fool only)"),
limit: int = typer.Option(200, help="hf row cap / per-ticker filing cap"),
output: Path | None = typer.Option(None, help="output parquet (default: data/processed/features_labelled.parquet)"),
skip_labels: bool = typer.Option(False, help="skip yfinance label download (offline mode)"),
) -> None:
"""Pull transcripts, extract features, attach T+1 excess-return labels."""
settings.processed_dir.mkdir(parents=True, exist_ok=True)
output = output or (settings.processed_dir / "features_labelled.parquet")
transcripts: Iterator[Transcript]
if source == "hf":
transcripts = load_hf_earnings_calls(limit=limit)
elif source == "edgar":
from eca.ingest.edgar import fetch_many
if not tickers:
raise typer.BadParameter("--tickers required for edgar")
transcripts = fetch_many([t.strip().upper() for t in tickers.split(",") if t.strip()], per_ticker=limit)
elif source == "motley_fool":
from eca.ingest.motley_fool import fetch_many
if not tickers:
raise typer.BadParameter("--tickers required for motley_fool")
typer.secho(
"Motley Fool ToS prohibits scraping. Use only for personal research.",
fg=typer.colors.YELLOW,
)
transcripts = fetch_many([t.strip().upper() for t in tickers.split(",") if t.strip()], per_ticker=limit)
else:
raise typer.BadParameter(f"unknown source: {source}")
feats = build_features_df(transcripts)
if feats.empty:
typer.secho("no transcripts ingested", fg=typer.colors.RED)
raise typer.Exit(code=1)
logger.info(f"built features for {len(feats)} transcripts")
if not skip_labels:
from eca.prices import attach_labels
feats = attach_labels(feats)
feats.to_parquet(output, index=False)
typer.secho(f"wrote {len(feats)} rows -> {output}", fg=typer.colors.GREEN)
# ----- train -----
@app.command("train")
def train_cmd(
input: Path = typer.Option(
None,
help="features parquet with labels (default: data/processed/features_labelled.parquet)",
),
n_splits: int = typer.Option(5, help="walk-forward CV folds"),
) -> None:
"""Train the LightGBM directional classifier."""
from eca.model.train import train
input = input or (settings.processed_dir / "features_labelled.parquet")
if not input.exists():
typer.secho(f"missing {input}; run `build-dataset` first", fg=typer.colors.RED)
raise typer.Exit(code=1)
df = pd.read_parquet(input)
result = train(df, n_splits=n_splits)
typer.secho(
f"cv_mean_accuracy={result.mean_accuracy:.3f} cv_mean_auc={result.mean_auc:.3f}",
fg=typer.colors.GREEN,
)
top = list(result.feature_importances.items())[:10]
typer.echo("top features:")
for k, v in top:
typer.echo(f" {k:30s} {v:.1f}")
# ----- predict (cache predictions for the backtest endpoint) -----
@app.command("predict")
def predict_cmd(
input: Path | None = typer.Option(None),
output: Path | None = typer.Option(None),
) -> None:
"""Score every labelled row and cache predictions for backtesting."""
from eca.model.predict import load_model
input = input or (settings.processed_dir / "features_labelled.parquet")
output = output or (settings.processed_dir / "predictions.parquet")
if not input.exists():
typer.secho(f"missing {input}", fg=typer.colors.RED)
raise typer.Exit(code=1)
df = pd.read_parquet(input)
predictor = load_model()
df["prob_up"] = predictor.predict_frame(df)
df.to_parquet(output, index=False)
typer.secho(f"wrote predictions ({len(df)} rows) -> {output}", fg=typer.colors.GREEN)
# ----- backtest -----
@app.command("backtest")
def backtest_cmd(
ticker: str | None = typer.Option(None, help="restrict to one ticker"),
threshold: float = typer.Option(0.0, help="trade only when |prob - 0.5| > threshold"),
input: Path | None = typer.Option(None),
) -> None:
"""Run the vectorized backtest and print summary."""
from eca.backtest import run_backtest
input = input or (settings.processed_dir / "predictions.parquet")
if not input.exists():
typer.secho(f"missing {input}; run `predict` first", fg=typer.colors.RED)
raise typer.Exit(code=1)
df = pd.read_parquet(input)
if ticker:
df = df[df["ticker"].str.upper() == ticker.upper()]
if df.empty:
typer.secho(f"no predictions for {ticker}", fg=typer.colors.RED)
raise typer.Exit(code=1)
result = run_backtest(df, threshold=threshold)
for k, v in result.summary().items():
typer.echo(f"{k:24s} {v}")
# ----- serve -----
@app.command("serve")
def serve(host: str = "127.0.0.1", port: int = 8000, reload: bool = False) -> None:
"""Run the FastAPI app via uvicorn."""
import uvicorn
uvicorn.run("eca.api.main:app", host=host, port=port, reload=reload)
if __name__ == "__main__":
app()