Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions .github/workflows/python-ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
name: Python CI

on:
push:
branches: ["**"]
pull_request:
branches: ["**"]

permissions:
contents: read

concurrency:
group: python-ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:
test:
name: pytest (${{ matrix.os }})
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest]
python-version: ["3.11"]

steps:
- name: Checkout
uses: actions/checkout@v5

- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python-version }}
cache: "pip"
cache-dependency-path: pyproject.toml

- name: Install package with dev extras
run: pip install -e '.[dev]'

- name: Run pytest
run: pytest -q
65 changes: 65 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,3 +112,68 @@ Technology expertise is supplied through composable skills in
`.github/skills/`. Shared skills cover Python architecture, engineering,
testing, application security, supply-chain security, platform engineering, and
cross-platform Windows/Linux behavior.

## `agentict` CLI: one-time PESTLE signal monitoring

`agentict` is a Python CLI that runs a single pass over a Markdown watchlist,
gathers best-effort PESTLE (Political, Economic, Social, Technological,
Legal, Environmental) signals per ticker/exchange pair, and prints a
tabular report ending in an `Invest` / `Not` / `no data available` verdict.

### Installation

Requires Python 3.10+.

```bash
# From a clone of this repository
pip install .

# Or, for development (adds pytest/requests-mock)
pip install -e ".[dev]"
```

### Watchlist format

Watchlists are Markdown files that alternate `# Stock exchange` and
`# Tickers` headings:

```markdown
# Stock exchange
NASDAQ

# Tickers
- AAPL
- MSFT

# Stock exchange
LSE

# Tickers
- BARC
```

### Usage

```bash
agentict monitor --file watchlist.md
```

```
DISCLAIMER: This report is generated by an automated AI system...

Ticker | Exchange | Verdict | Reason
-------+----------+-------------------+------------------------------------------
AAPL | NASDAQ | Invest | Positive keyword signal (3) outweighs...
MSFT | NASDAQ | no data available | duplicate across exchanges: 'MSFT'...
```

Options:

- `--file <path>` (required): path to the watchlist Markdown file.
- `--output <path>` (optional): write the report to a file instead of stdout.
- `--analyst heuristic|llm` (optional): select the Financial Analyst
implementation used to produce verdicts. Defaults to the network-free
`heuristic` analyst, or the `AGENTICT_ANALYST` environment variable.

Exit codes: `0` success, `2` usage error or malformed watchlist (no report
emitted), `1` unexpected error.
29 changes: 29 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[project]
name = "agentict"
version = "0.1.0"
description = "One-time PESTLE signal monitoring for market watchlists via CLI."
readme = "README.md"
requires-python = ">=3.10"
license = { text = "MIT" }
dependencies = [
"requests>=2.31",
]

[project.optional-dependencies]
dev = [
"pytest>=7.4",
"requests-mock>=1.11",
]

[project.scripts]
agentict = "agentict.cli:main"

[tool.hatch.build.targets.wheel]
packages = ["src/agentict"]

[tool.pytest.ini_options]
testpaths = ["tests"]
3 changes: 3 additions & 0 deletions src/agentict/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
"""agentict: one-time PESTLE signal monitoring for market watchlists."""

__version__ = "0.1.0"
1 change: 1 addition & 0 deletions src/agentict/agents/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Empty package marker for agentict.agents."""
21 changes: 21 additions & 0 deletions src/agentict/agents/base.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
"""Financial Analyst protocol shared by all agent implementations."""

from __future__ import annotations

from typing import Protocol, runtime_checkable

from ..models import PestleSignals, VerdictResult


@runtime_checkable
class FinancialAnalyst(Protocol):
"""Produces an investment verdict from aggregated PESTLE signals."""

def assess(self, ticker: str, exchange: str, signals: PestleSignals) -> VerdictResult:
"""Assess ``ticker``/``exchange`` given aggregated PESTLE signal text.

Implementations must be total: they must always return a
:class:`VerdictResult` and never raise for merely weak/absent
signal data (that case should resolve to ``Verdict.NO_DATA``).
"""
...
47 changes: 47 additions & 0 deletions src/agentict/agents/factory.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
"""Selects a Financial Analyst implementation by name or environment.

Reads ``AGENTICT_ANALYST`` (values: ``heuristic`` | ``llm``) when ``name`` is
not passed explicitly. Defaults to ``heuristic``.
"""

from __future__ import annotations

import os

from ..errors import AnalystConfigurationError
from .base import FinancialAnalyst
from .heuristic import HeuristicFinancialAnalyst

_ENV_VAR = "AGENTICT_ANALYST"
_DEFAULT_ANALYST = "heuristic"


def get_financial_analyst(name: str | None = None) -> FinancialAnalyst:
"""Construct the selected :class:`FinancialAnalyst` implementation.

Args:
name: Explicit analyst name (``"heuristic"`` or ``"llm"``). When
``None``, falls back to the ``AGENTICT_ANALYST`` environment
variable, and then to ``"heuristic"``.

Raises:
AnalystConfigurationError: if ``name`` (or the environment variable)
is set to an unrecognized value.
"""
selected = (name or os.environ.get(_ENV_VAR) or _DEFAULT_ANALYST).strip().lower()

if selected == "heuristic":
return HeuristicFinancialAnalyst()

if selected == "llm":
# Imported lazily here (rather than at module top) purely to keep
# the dependency direction obvious; agents.llm itself already lazily
# imports any real SDK, so this import is always safe.
from .llm import LlmFinancialAnalyst

return LlmFinancialAnalyst()

raise AnalystConfigurationError(
f"Unknown AGENTICT_ANALYST/--analyst value: '{selected}'. "
"Valid options are: 'heuristic', 'llm'."
)
128 changes: 128 additions & 0 deletions src/agentict/agents/heuristic.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
"""Deterministic, no-network default Financial Analyst implementation.

IMPORTANT — illustrative heuristic, not a certified scoring formula:
This module implements a simple keyword-polarity tally over aggregated
PESTLE signal text purely as a deterministic, offline stand-in so the CLI
has a working default with no external dependencies. It is NOT intended to
represent real investment analysis or a certified/validated scoring model.
In a production deployment the intent is for verdicts to come from genuine
analyst judgment (e.g. an LLM-backed :class:`agentict.agents.llm.LlmFinancialAnalyst`
or a human), not from a fixed keyword formula.
"""

from __future__ import annotations

from ..models import PESTLE_CATEGORIES, PestleSignals, Verdict, VerdictResult

#: Minimum number of PESTLE categories that must carry usable signal text
#: before the heuristic will attempt a directional verdict at all.
_MIN_CATEGORIES_WITH_SIGNAL = 2

#: When the positive/negative keyword tally is this close (inclusive), the
#: signal is treated as materially conflicting rather than decisive.
_CONFLICT_MARGIN = 1

_POSITIVE_KEYWORDS = (
"growth",
"profit",
"profitable",
"expansion",
"record revenue",
"strong demand",
"beat expectations",
"upgrade",
"innovation",
"favorable",
"favourable",
"surplus",
"stable regulation",
"strong",
"outperform",
"bullish",
"gain",
"growing",
)

_NEGATIVE_KEYWORDS = (
"lawsuit",
"recall",
"decline",
"loss",
"layoffs",
"downgrade",
"investigation",
"scandal",
"fine",
"penalty",
"boycott",
"shortage",
"instability",
"unfavorable",
"unfavourable",
"weak",
"bearish",
"bankruptcy",
"recession",
"sanctions",
)


class HeuristicFinancialAnalyst:
"""Default, deterministic, no-network Financial Analyst."""

def assess(self, ticker: str, exchange: str, signals: PestleSignals) -> VerdictResult:
usable_categories = signals.non_empty_categories()
if len(usable_categories) < _MIN_CATEGORIES_WITH_SIGNAL:
return VerdictResult(
verdict=Verdict.NO_DATA,
rationale=(
f"Only {len(usable_categories)} of {len(PESTLE_CATEGORIES)} PESTLE "
f"categories have usable signal text for {ticker} ({exchange}); "
f"at least {_MIN_CATEGORIES_WITH_SIGNAL} are required."
),
)

text = signals.combined_text().lower()
positive_hits = sum(text.count(keyword) for keyword in _POSITIVE_KEYWORDS)
negative_hits = sum(text.count(keyword) for keyword in _NEGATIVE_KEYWORDS)

if abs(positive_hits - negative_hits) <= _CONFLICT_MARGIN and (
positive_hits > 0 or negative_hits > 0
):
return VerdictResult(
verdict=Verdict.NO_DATA,
rationale=(
f"Signal for {ticker} ({exchange}) is materially conflicting "
f"(positive keyword hits={positive_hits}, negative keyword "
f"hits={negative_hits}); withholding a directional verdict."
),
)

if positive_hits == 0 and negative_hits == 0:
return VerdictResult(
verdict=Verdict.NO_DATA,
rationale=(
f"No decisive positive or negative keyword signal found for "
f"{ticker} ({exchange}) despite {len(usable_categories)} "
"populated PESTLE categories."
),
)

if positive_hits > negative_hits:
return VerdictResult(
verdict=Verdict.INVEST,
rationale=(
f"Positive keyword signal ({positive_hits}) outweighs negative "
f"signal ({negative_hits}) across {len(usable_categories)} PESTLE "
f"categories for {ticker} ({exchange})."
),
)

return VerdictResult(
verdict=Verdict.NOT,
rationale=(
f"Negative keyword signal ({negative_hits}) outweighs positive "
f"signal ({positive_hits}) across {len(usable_categories)} PESTLE "
f"categories for {ticker} ({exchange})."
),
)
Loading
Loading