Skip to content
Merged
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
1 change: 0 additions & 1 deletion alphoryn/config/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,6 @@ class AlphorynConfig(BaseModel):
exchange: str | None = None
session_money_budget: float | None = None
stop_loss_pct: float = 0.02
max_startup_latency_seconds: int = 60
currency: str = "USD"
memory_db_path: str = "~/.alphoryn/memory.db"

Expand Down
10 changes: 0 additions & 10 deletions alphoryn/scheduler/scheduler.py
Original file line number Diff line number Diff line change
Expand Up @@ -181,22 +181,12 @@ def wait_for_candle_close(self, target: datetime, *, _sleep: object = None) -> N
target: The UTC datetime to wait until.
_sleep: Injectable sleep callable (default: time.sleep).
Injected in tests to avoid real sleeps.

Emits a WARN to stderr if the wait exceeds max_startup_latency_seconds.
"""
_do_sleep = _sleep if _sleep is not None else time.sleep

now = datetime.now(UTC)
total_secs = max(0.0, (target - now).total_seconds())

if total_secs > self._cfg.max_startup_latency_seconds:
typer.echo(
f"WARN: {total_secs:.0f}s wait to next candle exceeds "
f"max_startup_latency_seconds={self._cfg.max_startup_latency_seconds}. "
"Proceeding.",
err=True,
)

close_str = target.strftime("%Y-%m-%d %H:%M UTC")
bar_width = 30
enc = getattr(sys.stdout, "encoding", None) or ""
Expand Down
1 change: 0 additions & 1 deletion config.json.example
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,5 @@
"currency": "USD",
"stop_loss_pct": 0.02,
"session_money_budget": null,
"max_startup_latency_seconds": 60,
"memory_db_path": "~/.alphoryn/memory.db"
}
6 changes: 4 additions & 2 deletions specs/001-etf-paper-trading-agent/checklists/requirements.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Specification Quality Checklist: Alphoryn — Automated ETF Paper Trading System
# Specification Quality Checklist: Alphoryn — Automated Ticker Paper Trading System

**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-07-03
Expand Down Expand Up @@ -33,7 +33,9 @@
## Notes

- All items pass. Spec updated 2026-07-07 (two passes) to reflect V0.0.1 implemented state:
- Terminology updated from "ETF" to "ticker" throughout (PR #99)
- Terminology updated from "ETF" to "ticker" in spec.md and data-model.md (2026-07-07 pass;
PR #99 covered the codebase but missed contracts/agents.md, contracts/report-context.md,
and research.md — those were brought in line separately on 2026-07-21)
- Config: removed `exchange`, added `extended_hours` and `memory_db_path`; tickers is now `list[str]` (min 2)
- Session ID corrected to sequential format (`run-3/session-0001`)
- US1 scenario 1 session count corrected (24H/1H = 24 sessions, not 6)
Expand Down
58 changes: 33 additions & 25 deletions specs/001-etf-paper-trading-agent/contracts/agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,37 +10,43 @@ memory bank — they are in-process Python objects.

## Decision Handoff (`main_agent` → `execution/agent.py`)

After investigation, `main_agent` produces a `SessionDecision` containing one `ETFDecision`
per ETF. This object is passed directly to `execution/agent.py` as a Python dataclass.
After investigation, `main_agent` produces a `SessionDecision` containing one `AssetDecision`
per ticker, in a list (supports any number of configured tickers, not just two). This object
is passed directly to `execution/agent.py` as a Python dataclass.

```python
@dataclass(frozen=True)
class ETFDecision:
etf: str
class AssetDecision:
ticker: str
action: Literal["BUY", "SELL", "HOLD"]
strategy: Literal["MEAN_REVERSION", "MOMENTUM"]
strategy: Literal["MEAN_REVERSION", "MOMENTUM"] | None
lot_size: int | None # shares; None if action is SELL or HOLD
exit_target: dict | None # None if action is not BUY
reasoning: str # emitted to telemetry; not stored in DB

@dataclass(frozen=True)
class SessionDecision:
session_id: str
etf1: ETFDecision
etf2: ETFDecision
decisions: list[AssetDecision]
```

`exit_target` format matches `Position.exit_target` in data-model.md:
- Mean Reversion: `{"type": "price_level", "value": 123.45}`
- Momentum: `{"type": "trailing_stop", "trail_pct": 0.015}`

**Execution sequence in `execution/agent.py`** (per ETF, sequential):
1. If action is HOLD: emit `AGENT_DECISION` telemetry event, return
2. Budget check via `alpaca-py` account API → emit `BUDGET_CHECK` event
3. If budget insufficient: emit `ORDER_FAILED`, skip ETF
4. Place market order via `alpaca-py` → emit `ORDER_PLACED` or `ORDER_FAILED`
5. On success: write `Position` record to memory bank with `status=OPEN`
6. On order placed but confirmation timeout: log warning, mark position OPEN, monitor will handle
**Execution sequence in `execution/agent.py`** (per ticker, sequential, via `execute()` iterating `decision.decisions`):
1. If action is HOLD: skip
2. If an existing `OPEN` position exists for the same ticker: force HOLD (position-blocked, FR-014)
3. Budget check via `alpaca-py` account API
4. If budget insufficient: skip the ticker's order
5. Place market order via `alpaca-py`
6. On success: write `Position` record to memory bank with `status=OPEN`

**Known gap**: unlike `main_agent`, `monitor`, and `feedback_agent`, `execution/agent.py`
does not currently have a `TelemetryLogger` wired in and emits no telemetry events
(`ORDER_PLACED`/`ORDER_FAILED`/`BUDGET_CHECK`, though declared in `telemetry/logger.py`'s
`EVENT_TYPES`, are never emitted in the current implementation). See research.md
§Telemetry for the full known-gap note.

---

Expand All @@ -66,7 +72,7 @@ The scheduler passes a `FeedbackInput` to `agents/feedback_agent.py` for each po
class FeedbackInput:
position_id: int
session_id: str # session in which the position was opened
etf: str
ticker: str
strategy: Literal["MEAN_REVERSION", "MOMENTUM"]
html_report_path: str # from Session.html_report_path of the entry session
entry_price: float
Expand All @@ -75,19 +81,19 @@ class FeedbackInput:
```

**Feedback agent responsibilities**:
1. Fetch 1H candle close at current time via Alpaca MCP `get_bars`
1. Fetch the candle close price at evaluation time via the same `MarketDataClient` (`market_data/client.py`) the Investigation Agent uses, querying the specific past timestamp rather than the latest candle
2. Read HTML report at `html_report_path` to extract the original investment thesis
3. Compare thesis to outcome → produce `CORRECT`, `INCORRECT`, or `NEUTRAL` judgment
4. Write `FeedbackEvaluation` record to memory bank
5. Update `Position.status` to `EVALUATED`
6. Write `MemoryEntry` record for the ETF/strategy pair
6. Write `MemoryEntry` record for the ticker/strategy pair
7. Emit `AGENT_DECISION` telemetry event

**Retry policy** (spec FR-016a): up to 3 attempts per position. On 3rd failure:
- Write `FeedbackEvaluation` with `attempt_count=3` and partial data
- Update `Position.status` to `EVALUATION_FAILED`
- Emit warning telemetry event
- Unblock the ETF for new positions
- Emit `EVALUATION_FAILED` telemetry event (`position_id`, `ticker`, `error`)
- Unblock the ticker for new positions

**Ordering**: feedback evaluation runs before investigation in the same session. If
evaluation for multiple positions is due in the same session, they run sequentially.
Expand All @@ -100,12 +106,14 @@ The monitor communicates with the rest of the system exclusively through the mem
There is no inter-thread signaling — the monitor writes, the scheduler reads.

**On exit condition detected** (`monitor/monitor.py`):
1. Call `alpaca-py` `close_position(etf)` to close the position on Alpaca
1. Call `alpaca-py` `close_position(ticker)` to close the position on Alpaca
2. On success: write `Position.exit_price`, `Position.exit_time`, `Position.exit_reason`,
update `Position.status` to `CLOSED_STOP_LOSS` / `CLOSED_PROFIT_TARGET` / `CLOSED_WINDOW_EXPIRY`
3. Emit appropriate telemetry event (`STOP_LOSS_TRIGGERED`, `PROFIT_TARGET_TRIGGERED`,
`WINDOW_EXPIRY_TRIGGERED`) followed by `POSITION_CLOSED`
4. On `close_position` API failure: emit `ORDER_FAILED`, retry next poll cycle
3. Emit the appropriate trigger telemetry event (`STOP_LOSS_TRIGGERED`, `PROFIT_TARGET_TRIGGERED`,
or `WINDOW_EXPIRY_TRIGGERED`, payload `ticker`/`exit_price`/`exit_reason`) followed by
`POSITION_CLOSED` (payload `ticker`/`status`)
4. On `close_position` API failure: the bank is left unchanged and no telemetry is emitted;
the position remains `OPEN` and is retried on the next poll cycle

**Monitor lifecycle**:
- Started as `threading.Thread` by `cli/main.py` at run startup, before the first session
Expand All @@ -129,6 +137,6 @@ open_positions = session.query(Position).filter(
```

These are passed to the session loop and monitor at startup. Per FR-019: if a position
exists for an ETF from a prior run, that ETF is blocked from new Buy orders until the
exists for a ticker from a prior run, that ticker is blocked from new Buy orders until the
position closes. The block is enforced in `execution/agent.py` at order time by checking
for an existing OPEN position for the same ETF before proceeding.
for an existing OPEN position for the same ticker before proceeding.
51 changes: 23 additions & 28 deletions specs/001-etf-paper-trading-agent/contracts/cli.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# CLI Contract: Alphoryn

**Phase 1 output** | **Date**: 2026-07-03 | **Plan**: [../plan.md](../plan.md)
**Phase 1 output** | **Date**: 2026-07-03 (updated 2026-07-21) | **Plan**: [../plan.md](../plan.md)

Implemented by: `alphoryn/cli/main.py` (Typer)

Expand All @@ -9,49 +9,45 @@ Implemented by: `alphoryn/cli/main.py` (Typer)
## Command: `alphoryn run`

Start a paper trading session. Config file is the base; CLI options override individual
fields. At least `--etf1` and `--etf2` must be present (via config or CLI).
fields. At least `--tickers` (2 or more, comma-separated) must be present, via config or CLI.

```
Usage: alphoryn run [OPTIONS]

Options:
--config PATH Path to JSON config file. Default: ./config.json
--etf1 TEXT ETF 1 ticker (US-listed, e.g. SPY). Overrides config.
--etf2 TEXT ETF 2 ticker (US-listed, e.g. QQQ). Overrides config.
--tickers TEXT Comma-separated ticker symbols, e.g. SPY,QQQ. Overrides config.
--exchange TEXT Optional/informational. Alpaca routes automatically. Overrides config.
--timeframe TEXT Candle timeframe: 30min | 1H | 4H. Overrides config.
--duration TEXT Run duration: e.g. 8H | 24H. Overrides config.
--budget FLOAT Session money budget in USD. Overrides config. 0 = no limit.
--budget FLOAT Session money budget in USD. Overrides config. 0 or negative = no limit.
--stop-loss FLOAT Stop-loss percentage, e.g. 0.02 for 2%. Overrides config.
--help Show this message and exit.
```

**Known gap**: `extended_hours` and `memory_db_path` are config-only — there is no
`--extended-hours` or `--memory-db-path` CLI override for `run` (unlike `status`/`history`,
which take `--db`).

**Startup output** (to stdout before first candle close):
```
Alphoryn v0.0.1 — Paper Trading
ETFs: SPY / QQQ | Timeframe: 1H | Duration: 24H
Tickers: SPY, QQQ | Timeframe: 1H | Duration: 24H
Sessions planned: 6
Memory bank: /home/user/.alphoryn/memory.db — 0 open positions loaded
Waiting for next candle close at 2026-07-03 15:00 UTC (12 min 34 sec)
```

**During investigation** (heartbeat, every 5 minutes):
**Session completion** (one line per session; ticker decisions are pipe-separated,
not one line per ticker):
```
[run-1/session-a3f7] investigating... 10 min elapsed
[run-1/session-a3f7] investigating... 15 min elapsed
```

**Session completion**:
```
[run-1/session-a3f7] ETF1: MEAN_REVERSION → BUY (executed)
[run-1/session-a3f7] ETF2: MOMENTUM → HOLD
[run-1/session-a3f7] Report: reports/run-1/session-a3f7.html
[run-1/session-a3f7] DECISION SPY: BUY (MEAN_REVERSION) | QQQ: HOLD (MOMENTUM)
[run-1/session-a3f7] Report -> reports/run-1/session-a3f7.html
```

**Failure / skip**:
```
[run-1/session-b9c2] WARN: investigation timeout — Hold forced on all ETFs
[run-1/session-c1d4] SKIP: market data unavailable — session not counted against total
[run-1/session-b9c2] SKIPPED investigation budget exceeded
[session-c1d4] MARKET_CLOSED — waiting for next candle
```

**Exit codes**:
Expand All @@ -76,21 +72,21 @@ Options:
--help
```

**Output**:
**Output** (one line per ticker configured for the latest run, from its config snapshot):
```
Current run: run-1 (started 2026-07-03 14:00 UTC)
Sessions: 3 completed, 3 remaining

Open positions:
ETF1 SPY MEAN_REVERSION BUY @ 540.12 Stop: 529.32 Status: OPEN
ETF2 QQQ (no open position)
SPY MEAN_REVERSION BUY @ 540.12 Stop: 529.32 Status: OPEN
QQQ (no open position)
```

---

## Command: `alphoryn history`

Show session history and feedback evaluations from the memory bank.
Show session history from the memory bank.

```
Usage: alphoryn history [OPTIONS]
Expand All @@ -101,11 +97,10 @@ Options:
--help
```

**Output** (table, most recent first):
**Output** (table, most recent first; one column per ticker in the run's config snapshot):
```
Session Candle Close ETF1 ETF2
run-1/session-a3f7 2026-07-03 14:00 MR BUY (exec) MOM HOLD
run-1/session-b9c2 2026-07-03 15:00 MOM HOLD MOM SELL (exec)
Session Candle Close SPY QQQ
run-1/session-a3f7 2026-07-03 14:00 MR -> BUY (exec) MOM -> HOLD
run-1/session-b9c2 2026-07-03 15:00 MOM -> HOLD MOM -> SELL (exec)
...
```

18 changes: 8 additions & 10 deletions specs/001-etf-paper-trading-agent/contracts/config-schema.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,12 @@ No secrets belong in this file — credentials are fetched from Google Secret Ma

```json
{
"etf1": "SPY",
"etf2": "QQQ",
"tickers": ["SPY", "QQQ"],
"candle_timeframe": "1H",
"run_duration": "24H",
"extended_hours": false,
"session_money_budget": 1000.0,
"stop_loss_pct": 0.02,
"max_startup_latency_seconds": 60,
"currency": "USD",
"memory_db_path": "~/.alphoryn/memory.db"
}
Expand All @@ -29,20 +28,19 @@ No secrets belong in this file — credentials are fetched from Google Secret Ma

| Field | Required | Type | Allowed Values | Notes |
|---|---|---|---|---|
| `etf1` | Yes | string | Any valid ticker | Exchange suffix auto-applied from `exchange` if absent |
| `etf2` | Yes | string | Any valid ticker | Must differ from `etf1` |
| `candle_timeframe` | No | string | `"30min"`, `"1H"`, `"4H"` | Default: `"1H"` |
| `tickers` | Yes | list of string | Minimum 2 US-listed tickers | Evaluated independently; no cross-ticker correlation logic |
| `candle_timeframe` | No | string | `"10min"`, `"15min"`, `"30min"`, `"1H"`, `"4H"` | Default: `"1H"` |
| `run_duration` | No | string | `"NHM"` format, e.g. `"24H"`, `"8H"` | Default: `"24H"` |
| `exchange` | No | string | `"NYSE"`, `"NASDAQ"`, `"AMEX"` | Informational only; Alpaca routes automatically. Market hours from Alpaca calendar API. Default: omitted (auto-detected from ticker). |
| `extended_hours` | No | boolean | `true`/`false` | Default: `false`. Allows pre/post-market execution; testing affordance. |
| `exchange` | No | string or null | Any string | Optional, informational only — Alpaca routes US equities automatically; market hours from Alpaca calendar API. Default: `null`. |
| `session_money_budget` | No | float or null | Positive USD amount | `null` = no budget limit |
| `stop_loss_pct` | No | float | `0.001`–`0.20` | Default: `0.02` (2%). Applied as hard config value at trade entry. |
| `max_startup_latency_seconds` | No | integer | `10`–`300` | Default: `60`. System warns if exceeded; never blocks. |
| `stop_loss_pct` | No | float | `(0, 1)` exclusive | Default: `0.02` (2%). Applied as hard config value at trade entry. |
| `currency` | No | string | `"USD"` | Default: `"USD"`. Only USD supported in v0.0.1 (Alpaca paper accounts are USD). |
| `memory_db_path` | No | string | Valid filesystem path | Default: `~/.alphoryn/memory.db` |

## Validation Rules

- `etf1` ≠ `etf2` (enforced by Pydantic validator)
- `tickers` must contain at least 2 symbols (enforced by Pydantic validator)
- `run_duration` must be evenly divisible by `candle_timeframe` — if not, system warns and
rounds down at startup (spec FR-003); this is a warning, not a config error
- `stop_loss_pct` must be in range `(0, 1)` exclusive
Expand Down
Loading
Loading