build(agent): melter#14fd4b iteration
This commit is contained in:
parent
df54551707
commit
570c27c6ba
|
|
@ -0,0 +1,21 @@
|
||||||
|
node_modules/
|
||||||
|
.npmrc
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
__tests__/
|
||||||
|
coverage/
|
||||||
|
.nyc_output/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
.cache/
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
tmp/
|
||||||
|
.tmp/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
*.egg-info/
|
||||||
|
.pytest_cache/
|
||||||
|
READY_TO_PUBLISH
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
Architecture and contributor guidance for idea115-taxalpha-studio-declarative
|
||||||
|
|
||||||
|
Overview
|
||||||
|
- Phase-0 skeleton for TaxAlpha Studio: canonical IR, a tiny DSL parser,
|
||||||
|
a deterministic greedy optimizer (warm-start), and two simple adapters
|
||||||
|
(custodian mock and CSV reader).
|
||||||
|
|
||||||
|
Tech stack
|
||||||
|
- Python 3.8+
|
||||||
|
- Packaging: setuptools (pyproject.toml)
|
||||||
|
- Testing: pytest
|
||||||
|
|
||||||
|
Repository layout
|
||||||
|
- idea115_taxalpha_studio_declarative/: package source
|
||||||
|
- ir.py: canonical dataclasses (TaxLot, Account, HarvestAction, PlanDelta, AuditLog)
|
||||||
|
- dsl.py: very small line-oriented DSL parser
|
||||||
|
- solver.py: deterministic greedy warm-start optimizer (Phase-0)
|
||||||
|
- adapters/: simple adapters (custodian mock, CSV reader)
|
||||||
|
- tests/: pytest tests
|
||||||
|
- pyproject.toml: package metadata and build-system
|
||||||
|
- README.md: project description
|
||||||
|
- AGENTS.md: this file
|
||||||
|
|
||||||
|
Developer rules
|
||||||
|
- Make minimal, well-scoped changes. Prefer small, correct edits.
|
||||||
|
- Use apply_patch for modifications.
|
||||||
|
- Add tests for new behavior and ensure pytest passes.
|
||||||
|
- test.sh must run `pytest` and `python3 -m build` successfully.
|
||||||
|
|
||||||
|
How to run tests
|
||||||
|
1. Install requirements if needed (Phase-0 uses stdlib only)
|
||||||
|
2. Run: `bash test.sh`
|
||||||
16
README.md
16
README.md
|
|
@ -1,3 +1,17 @@
|
||||||
# idea115-taxalpha-studio-declarative
|
# idea115-taxalpha-studio-declarative
|
||||||
|
|
||||||
Source logic for Idea #115
|
TaxAlpha Studio (Phase-0 skeleton)
|
||||||
|
|
||||||
|
This repository contains a Phase-0 skeleton for the TaxAlpha Studio project
|
||||||
|
— a declarative, auditable tax-aware harvesting and optimization engine.
|
||||||
|
|
||||||
|
What is included
|
||||||
|
- A tiny canonical IR (TaxLot, Account, HarvestAction, PlanDelta, AuditLog)
|
||||||
|
- A line-oriented DSL parser (very small, deterministic)
|
||||||
|
- A deterministic greedy warm-start optimizer (solver prototype)
|
||||||
|
- Two simple adapters: CSV reader and a mocked custodian adapter
|
||||||
|
- Tests and packaging metadata so the project builds and tests locally
|
||||||
|
|
||||||
|
Run tests
|
||||||
|
|
||||||
|
bash test.sh
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
"""idea115_taxalpha_studio_declarative
|
||||||
|
|
||||||
|
Minimal Phase-0 package for TaxAlpha Studio.
|
||||||
|
Provides a tiny DSL -> IR, a greedy solver warm-start, and simple adapters.
|
||||||
|
"""
|
||||||
|
from .ir import TaxLot, Account, HarvestAction, PlanDelta, AuditLog
|
||||||
|
from .dsl import parse_declarations
|
||||||
|
from .solver import optimize_harvest
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"TaxLot",
|
||||||
|
"Account",
|
||||||
|
"HarvestAction",
|
||||||
|
"PlanDelta",
|
||||||
|
"AuditLog",
|
||||||
|
"parse_declarations",
|
||||||
|
"optimize_harvest",
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
import csv
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import List
|
||||||
|
from ..ir import TaxLot
|
||||||
|
|
||||||
|
|
||||||
|
def read_taxlots_from_csv(path: str) -> List[TaxLot]:
|
||||||
|
"""Read a simple CSV with headers: id,acquisition_date,basis,quantity,market_value
|
||||||
|
|
||||||
|
Returns list of TaxLot. This adapter is intentionally minimal for Phase-0.
|
||||||
|
"""
|
||||||
|
lots = []
|
||||||
|
with open(path, newline="") as f:
|
||||||
|
rdr = csv.DictReader(f)
|
||||||
|
for row in rdr:
|
||||||
|
acq = row.get("acquisition_date")
|
||||||
|
acq_date = datetime.strptime(acq, "%Y-%m-%d").date() if acq else None
|
||||||
|
lot = TaxLot(
|
||||||
|
id=row.get("id"),
|
||||||
|
acquisition_date=acq_date,
|
||||||
|
basis=float(row.get("basis", 0)),
|
||||||
|
quantity=float(row.get("quantity", 0)),
|
||||||
|
market_value=float(row.get("market_value")) if row.get("market_value") else None,
|
||||||
|
)
|
||||||
|
lots.append(lot)
|
||||||
|
return lots
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
from datetime import date
|
||||||
|
from typing import List
|
||||||
|
from ..ir import TaxLot
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_mock_custodian_lots() -> List[TaxLot]:
|
||||||
|
"""Return a small deterministic set of lots for integration testing.
|
||||||
|
|
||||||
|
In a real adapter this would call custodian APIs and map responses to the
|
||||||
|
canonical IR. For Phase-0 we provide a mocked deterministic dataset.
|
||||||
|
"""
|
||||||
|
return [
|
||||||
|
TaxLot(id="lotA", acquisition_date=date(2019, 6, 1), basis=100.0, quantity=10, market_value=80.0),
|
||||||
|
TaxLot(id="lotB", acquisition_date=date(2020, 1, 15), basis=50.0, quantity=5, market_value=70.0),
|
||||||
|
TaxLot(id="lotC", acquisition_date=date(2018, 3, 3), basis=200.0, quantity=2, market_value=150.0),
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,63 @@
|
||||||
|
"""Tiny DSL parser for Phase-0.
|
||||||
|
|
||||||
|
Supports simple line-oriented declarations like:
|
||||||
|
|
||||||
|
TaxLot id=lot1 acquisition_date=2020-01-01 basis=100 quantity=10 market_value=80
|
||||||
|
Account id=acct1 jurisdiction=US tax_profile=taxable
|
||||||
|
|
||||||
|
This is intentionally minimal and deterministic for testing and the early
|
||||||
|
prototype. It returns IR dataclasses.
|
||||||
|
"""
|
||||||
|
from .ir import TaxLot, Account
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_kv_tokens(tokens):
|
||||||
|
data = {}
|
||||||
|
for t in tokens:
|
||||||
|
if "=" not in t:
|
||||||
|
continue
|
||||||
|
k, v = t.split("=", 1)
|
||||||
|
data[k.strip()] = v.strip()
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def parse_declarations(text: str):
|
||||||
|
"""Parse multiple lines of simple declarations into IR objects.
|
||||||
|
|
||||||
|
Returns dict with lists: {"taxlots": [...], "accounts": [...]}.
|
||||||
|
"""
|
||||||
|
taxlots = []
|
||||||
|
accounts = []
|
||||||
|
for raw in text.splitlines():
|
||||||
|
line = raw.strip()
|
||||||
|
if not line or line.startswith("#"):
|
||||||
|
continue
|
||||||
|
parts = line.split()
|
||||||
|
kind = parts[0]
|
||||||
|
kv = _parse_kv_tokens(parts[1:])
|
||||||
|
if kind == "TaxLot":
|
||||||
|
acq = kv.get("acquisition_date")
|
||||||
|
acq_date = datetime.strptime(acq, "%Y-%m-%d").date() if acq else None
|
||||||
|
lot = TaxLot(
|
||||||
|
id=kv.get("id"),
|
||||||
|
acquisition_date=acq_date,
|
||||||
|
basis=float(kv.get("basis", 0)),
|
||||||
|
quantity=float(kv.get("quantity", 0)),
|
||||||
|
market_value=float(kv.get("market_value")) if kv.get("market_value") else None,
|
||||||
|
lot_status=kv.get("lot_status", "open"),
|
||||||
|
)
|
||||||
|
taxlots.append(lot)
|
||||||
|
elif kind == "Account":
|
||||||
|
acct = Account(
|
||||||
|
id=kv.get("id"),
|
||||||
|
jurisdiction=kv.get("jurisdiction", "unknown"),
|
||||||
|
tax_profile=kv.get("tax_profile", "unknown"),
|
||||||
|
)
|
||||||
|
accounts.append(acct)
|
||||||
|
else:
|
||||||
|
# unknown declarations ignored for now
|
||||||
|
continue
|
||||||
|
|
||||||
|
return {"taxlots": taxlots, "accounts": accounts}
|
||||||
|
|
@ -0,0 +1,39 @@
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import date
|
||||||
|
from typing import Optional, List, Any
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TaxLot:
|
||||||
|
id: str
|
||||||
|
acquisition_date: date
|
||||||
|
basis: float
|
||||||
|
quantity: float
|
||||||
|
market_value: Optional[float] = None
|
||||||
|
lot_status: str = "open"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Account:
|
||||||
|
id: str
|
||||||
|
jurisdiction: str
|
||||||
|
tax_profile: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class HarvestAction:
|
||||||
|
lot_id: str
|
||||||
|
sell_qty: float
|
||||||
|
date: date
|
||||||
|
expected_gain: float
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PlanDelta:
|
||||||
|
actions: List[HarvestAction] = field(default_factory=list)
|
||||||
|
metadata: dict = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AuditLog:
|
||||||
|
entries: List[Any] = field(default_factory=list)
|
||||||
|
|
@ -0,0 +1,61 @@
|
||||||
|
"""Very small solver prototype.
|
||||||
|
|
||||||
|
Provides a deterministic greedy warm-start optimizer for harvesting losses.
|
||||||
|
For Phase-0 we avoid external MILP dependencies and implement a clear,
|
||||||
|
deterministic algorithm suitable for unit testing. Future versions will
|
||||||
|
replace or augment this with MILP (e.g., using pulp or ortools).
|
||||||
|
"""
|
||||||
|
from datetime import date
|
||||||
|
from typing import List, Tuple
|
||||||
|
from .ir import TaxLot, HarvestAction, PlanDelta, AuditLog
|
||||||
|
|
||||||
|
|
||||||
|
def _lot_loss(lot: TaxLot) -> float:
|
||||||
|
"""Return (market_value - basis) * quantity. Positive=gain, negative=loss.
|
||||||
|
|
||||||
|
Note: if market_value is None treat as 0 to keep deterministic behavior.
|
||||||
|
"""
|
||||||
|
mv = lot.market_value if lot.market_value is not None else 0.0
|
||||||
|
return (mv - lot.basis) * lot.quantity
|
||||||
|
|
||||||
|
|
||||||
|
def optimize_harvest(lots: List[TaxLot], target_loss: float) -> Tuple[PlanDelta, AuditLog]:
|
||||||
|
"""Greedy harvest: choose lots with largest losses first until reaching
|
||||||
|
target_loss (in absolute terms). target_loss should be positive number
|
||||||
|
indicating total loss to realize (e.g., harvest $10,000 of losses).
|
||||||
|
|
||||||
|
Returns a PlanDelta and AuditLog.
|
||||||
|
"""
|
||||||
|
# Work only with lots that currently show a loss
|
||||||
|
losses = []
|
||||||
|
for l in lots:
|
||||||
|
loss = -_lot_loss(l) if _lot_loss(l) < 0 else 0.0
|
||||||
|
if loss > 0:
|
||||||
|
losses.append((loss, l))
|
||||||
|
|
||||||
|
# Sort descending by loss magnitude (largest losses first)
|
||||||
|
losses.sort(key=lambda x: x[0], reverse=True)
|
||||||
|
|
||||||
|
accumulated = 0.0
|
||||||
|
actions = []
|
||||||
|
audit = AuditLog()
|
||||||
|
|
||||||
|
for loss_amt, lot in losses:
|
||||||
|
if accumulated >= target_loss:
|
||||||
|
break
|
||||||
|
# sell entire lot for simplicity in this prototype
|
||||||
|
sell_qty = lot.quantity
|
||||||
|
expected_gain = _lot_loss(lot)
|
||||||
|
action = HarvestAction(lot_id=lot.id, sell_qty=sell_qty, date=date.today(), expected_gain=expected_gain)
|
||||||
|
actions.append(action)
|
||||||
|
accumulated += loss_amt
|
||||||
|
audit.entries.append({
|
||||||
|
"lot_id": lot.id,
|
||||||
|
"realized_loss": loss_amt,
|
||||||
|
"basis": lot.basis,
|
||||||
|
"market_value": lot.market_value,
|
||||||
|
})
|
||||||
|
|
||||||
|
metadata = {"target_loss": target_loss, "realized_loss": accumulated}
|
||||||
|
plan = PlanDelta(actions=actions, metadata=metadata)
|
||||||
|
return plan, audit
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=61.0","wheel"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "idea115-taxalpha-studio-declarative"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "TaxAlpha Studio: Declarative tax-aware portfolio optimization (Phase 0 skeleton)"
|
||||||
|
readme = "README.md"
|
||||||
|
license = {text = "MIT"}
|
||||||
|
authors = [{name = "OpenCode Agent"}]
|
||||||
|
requires-python = ">=3.8"
|
||||||
|
|
||||||
|
[project.urls]
|
||||||
|
Home = "https://example.com/idea115"
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
echo "Installing package in editable mode..."
|
||||||
|
python3 -m pip install -e .
|
||||||
|
|
||||||
|
echo "Running pytest..."
|
||||||
|
pytest -q
|
||||||
|
|
||||||
|
echo "Building package for verification..."
|
||||||
|
python3 -m build
|
||||||
|
|
||||||
|
echo "All done."
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
from idea115_taxalpha_studio_declarative.adapters.custodian_adapter import fetch_mock_custodian_lots
|
||||||
|
from idea115_taxalpha_studio_declarative.solver import optimize_harvest
|
||||||
|
|
||||||
|
|
||||||
|
def test_optimize_harvest_realizes_target_loss():
|
||||||
|
lots = fetch_mock_custodian_lots()
|
||||||
|
# calculate total available loss from mock data: lotA loss = (80-100)*10 = -200 -> loss 200
|
||||||
|
# lotC loss = (150-200)*2 = -100 -> loss 100
|
||||||
|
# total loss available = 300
|
||||||
|
plan, audit = optimize_harvest(lots, target_loss=250)
|
||||||
|
# Ensure we realized at least the requested loss
|
||||||
|
assert plan.metadata["realized_loss"] >= 250
|
||||||
|
# Actions should include the largest-loss lots first (lotA then lotC)
|
||||||
|
ids = [a.lot_id for a in plan.actions]
|
||||||
|
assert "lotA" in ids
|
||||||
|
assert "lotC" in ids or plan.metadata["realized_loss"] >= 300
|
||||||
Loading…
Reference in New Issue