chore: add docs sync guardrails and spec documents
This commit is contained in:
@@ -0,0 +1,23 @@
|
|||||||
|
name: Docs Sync Check
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
docs-sync-check:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Setup Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.11"
|
||||||
|
|
||||||
|
- name: Verify docs sync
|
||||||
|
run: |
|
||||||
|
python3 scripts/check_docs_sync.py --against origin/${{ github.base_ref }} --head ${{ github.sha }}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
repos:
|
||||||
|
- repo: local
|
||||||
|
hooks:
|
||||||
|
- id: docs-sync-check
|
||||||
|
name: docs-sync-check
|
||||||
|
entry: python3 scripts/check_docs_sync.py --staged
|
||||||
|
language: system
|
||||||
|
pass_filenames: false
|
||||||
|
stages: [pre-commit]
|
||||||
@@ -113,6 +113,35 @@ PORTFOLIOHUB_DB_ENABLED=true portfoliohub portfolio list
|
|||||||
|
|
||||||
This repository now uses Alembic for versioned schema migrations. Future schema changes should be added under `migrations/versions`.
|
This repository now uses Alembic for versioned schema migrations. Future schema changes should be added under `migrations/versions`.
|
||||||
|
|
||||||
|
## Docs Sync Guardrail
|
||||||
|
|
||||||
|
To keep implementation and specification documents in sync, this repository provides an automated check.
|
||||||
|
|
||||||
|
Rules enforced:
|
||||||
|
|
||||||
|
- If files under `app/` change, `docs/portfoliohub-spec.md` must be updated in the same commit/PR.
|
||||||
|
- If files under `app/api/` or `app/schemas/` change, `docs/api-examples.md` must be updated in the same commit/PR.
|
||||||
|
- If files under `migrations/versions/` change, `docs/db-schema-spec.md` must be updated in the same commit/PR.
|
||||||
|
- Changed migration revision IDs must appear in `docs/db-schema-spec.md`.
|
||||||
|
|
||||||
|
Enable local pre-commit check:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -e .[dev]
|
||||||
|
pre-commit install
|
||||||
|
```
|
||||||
|
|
||||||
|
Run manually:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/check_docs_sync.py --staged
|
||||||
|
```
|
||||||
|
|
||||||
|
CI check:
|
||||||
|
|
||||||
|
- GitHub Actions workflow: `.github/workflows/docs-sync-check.yml`
|
||||||
|
- Runs automatically on pull requests.
|
||||||
|
|
||||||
## Authentication model
|
## Authentication model
|
||||||
|
|
||||||
PortfolioHub currently supports bearer tokens with three permission levels.
|
PortfolioHub currently supports bearer tokens with three permission levels.
|
||||||
|
|||||||
@@ -0,0 +1,287 @@
|
|||||||
|
# PortfolioHub API Examples
|
||||||
|
|
||||||
|
## 1. Purpose
|
||||||
|
|
||||||
|
This document contains example requests and responses for PortfolioHub HTTP APIs.
|
||||||
|
|
||||||
|
- Sync rule: when files under `app/api/` or `app/schemas/` change, this document must be updated in the same commit/PR.
|
||||||
|
- Companion spec: `docs/portfoliohub-spec.md`
|
||||||
|
|
||||||
|
## 2. Conventions
|
||||||
|
|
||||||
|
- Base URL example: `http://localhost:8000`
|
||||||
|
- Auth header format: `Authorization: Bearer <TOKEN>`
|
||||||
|
- JSON examples are representative and may omit some optional fields.
|
||||||
|
|
||||||
|
## 3. Health
|
||||||
|
|
||||||
|
### 3.1 GET /health
|
||||||
|
|
||||||
|
Request:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X GET "http://localhost:8000/health"
|
||||||
|
```
|
||||||
|
|
||||||
|
Response 200:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "ok"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. Portfolios
|
||||||
|
|
||||||
|
### 4.1 GET /portfolios
|
||||||
|
|
||||||
|
Request:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X GET "http://localhost:8000/portfolios?broker=upbit&status_filter=enabled" \
|
||||||
|
-H "Authorization: Bearer <READ_TOKEN>"
|
||||||
|
```
|
||||||
|
|
||||||
|
Response 200:
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"portfolio_id": "krw-btc",
|
||||||
|
"broker": "upbit",
|
||||||
|
"status": "enabled",
|
||||||
|
"symbol": "KRW-BTC",
|
||||||
|
"strategy_name": "target_value",
|
||||||
|
"strategy_params": {},
|
||||||
|
"cash_balance": 1000000.0,
|
||||||
|
"asset_quantity": 0.1,
|
||||||
|
"price": 100000000.0,
|
||||||
|
"profit_buffer": 0.0,
|
||||||
|
"current_strategy_set_id": "stv-001",
|
||||||
|
"created_at": "2026-04-03T00:00:00Z",
|
||||||
|
"updated_at": "2026-04-03T00:00:00Z",
|
||||||
|
"last_trade_at": "2026-04-03T00:00:00Z",
|
||||||
|
"total_value": 11000000.0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 GET /portfolios/{portfolio_id}
|
||||||
|
|
||||||
|
Request:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X GET "http://localhost:8000/portfolios/krw-btc?recent_trades=5" \
|
||||||
|
-H "Authorization: Bearer <READ_TOKEN>"
|
||||||
|
```
|
||||||
|
|
||||||
|
Response 200:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"portfolio": {
|
||||||
|
"portfolio_id": "krw-btc",
|
||||||
|
"broker": "upbit",
|
||||||
|
"status": "enabled",
|
||||||
|
"symbol": "KRW-BTC",
|
||||||
|
"strategy_name": "target_value",
|
||||||
|
"strategy_params": {},
|
||||||
|
"cash_balance": 1000000.0,
|
||||||
|
"asset_quantity": 0.1,
|
||||||
|
"price": 100000000.0,
|
||||||
|
"profit_buffer": 0.0,
|
||||||
|
"current_strategy_set_id": "stv-001",
|
||||||
|
"created_at": "2026-04-03T00:00:00Z",
|
||||||
|
"updated_at": "2026-04-03T00:00:00Z",
|
||||||
|
"last_trade_at": "2026-04-03T00:00:00Z"
|
||||||
|
},
|
||||||
|
"recent_entries": [
|
||||||
|
{
|
||||||
|
"portfolio_id": "krw-btc",
|
||||||
|
"recorded_at": "2026-04-03T00:00:00Z",
|
||||||
|
"action": "buy",
|
||||||
|
"price": 100000000.0,
|
||||||
|
"quantity": 0.1,
|
||||||
|
"fee_amount": 0.0,
|
||||||
|
"tax_amount": 0.0,
|
||||||
|
"cash_balance_after": 1000000.0,
|
||||||
|
"asset_quantity_after": 0.1,
|
||||||
|
"total_value_after": 11000000.0,
|
||||||
|
"profit_buffer_after": 0.0,
|
||||||
|
"strategy_set_id": "stv-001",
|
||||||
|
"note": ""
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"strategy_set": {
|
||||||
|
"id": "stv-001",
|
||||||
|
"strategy_name": "target_value",
|
||||||
|
"strategy_config": "{}",
|
||||||
|
"created_at": "2026-04-03T00:00:00Z"
|
||||||
|
},
|
||||||
|
"live_quantity": null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 POST /portfolios
|
||||||
|
|
||||||
|
Request:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST "http://localhost:8000/portfolios" \
|
||||||
|
-H "Authorization: Bearer <TRADE_WRITE_TOKEN>" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"portfolio_id": "krw-btc",
|
||||||
|
"broker": "upbit",
|
||||||
|
"status": "enabled",
|
||||||
|
"symbol": "KRW-BTC",
|
||||||
|
"strategy_name": "target_value",
|
||||||
|
"strategy_params": {},
|
||||||
|
"cash_balance": 1000000,
|
||||||
|
"asset_quantity": 0,
|
||||||
|
"asset_price": 0,
|
||||||
|
"profit_buffer": 0
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Response 201:
|
||||||
|
|
||||||
|
- Response body shape is `PortfolioDetailResponse` (same as 4.2).
|
||||||
|
|
||||||
|
### 4.4 PATCH /portfolios/{portfolio_id}
|
||||||
|
|
||||||
|
Request:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X PATCH "http://localhost:8000/portfolios/krw-btc" \
|
||||||
|
-H "Authorization: Bearer <TRADE_WRITE_TOKEN>" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"broker": "upbit",
|
||||||
|
"symbol": "KRW-BTC"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Response 200:
|
||||||
|
|
||||||
|
- Response body shape is `PortfolioDetailResponse`.
|
||||||
|
|
||||||
|
### 4.5 POST /portfolios/{portfolio_id}/buy
|
||||||
|
|
||||||
|
Request:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST "http://localhost:8000/portfolios/krw-btc/buy" \
|
||||||
|
-H "Authorization: Bearer <TRADE_WRITE_TOKEN>" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"quantity": 0.01, "price": 100000000, "fee": 0, "tax": 0, "note": ""}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Response 200:
|
||||||
|
|
||||||
|
- Response body shape is `PortfolioDetailResponse`.
|
||||||
|
|
||||||
|
### 4.6 POST /portfolios/{portfolio_id}/sell
|
||||||
|
|
||||||
|
Request:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST "http://localhost:8000/portfolios/krw-btc/sell" \
|
||||||
|
-H "Authorization: Bearer <TRADE_WRITE_TOKEN>" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"quantity": 0.01, "price": 100000000, "fee": 0, "tax": 0, "note": ""}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Response 200:
|
||||||
|
|
||||||
|
- Response body shape is `PortfolioDetailResponse`.
|
||||||
|
|
||||||
|
### 4.7 POST /portfolios/{portfolio_id}/invest
|
||||||
|
|
||||||
|
Request:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST "http://localhost:8000/portfolios/krw-btc/invest" \
|
||||||
|
-H "Authorization: Bearer <TRADE_WRITE_TOKEN>" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"movement_type": "deposit", "amount": 50000, "note": ""}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Response 200:
|
||||||
|
|
||||||
|
- Response body shape is `PortfolioDetailResponse`.
|
||||||
|
|
||||||
|
### 4.8 POST /portfolios/{portfolio_id}/transfer
|
||||||
|
|
||||||
|
Request:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST "http://localhost:8000/portfolios/krw-btc/transfer" \
|
||||||
|
-H "Authorization: Bearer <TRADE_WRITE_TOKEN>" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"direction": "to-buffer", "amount": 10000, "note": ""}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Response 200:
|
||||||
|
|
||||||
|
- Response body shape is `PortfolioDetailResponse`.
|
||||||
|
|
||||||
|
### 4.9 POST /portfolios/{portfolio_id}/reset
|
||||||
|
|
||||||
|
Request:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST "http://localhost:8000/portfolios/krw-btc/reset" \
|
||||||
|
-H "Authorization: Bearer <TRADE_WRITE_TOKEN>" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"infer_symbol": "KRW-BTC"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Response 200:
|
||||||
|
|
||||||
|
- Response body shape is `PortfolioDetailResponse`.
|
||||||
|
|
||||||
|
### 4.10 DELETE /portfolios/{portfolio_id}
|
||||||
|
|
||||||
|
Request:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X DELETE "http://localhost:8000/portfolios/krw-btc?keep_ledger=false" \
|
||||||
|
-H "Authorization: Bearer <ADMIN_TOKEN>"
|
||||||
|
```
|
||||||
|
|
||||||
|
Response 204:
|
||||||
|
|
||||||
|
- No content.
|
||||||
|
|
||||||
|
## 5. Common Error Examples
|
||||||
|
|
||||||
|
### 5.1 Missing token
|
||||||
|
|
||||||
|
Response 401:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"detail": "Missing bearer token"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 Invalid token
|
||||||
|
|
||||||
|
Response 401:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"detail": "Invalid token"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 Insufficient permission
|
||||||
|
|
||||||
|
Response 403:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"detail": "Insufficient permission"
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
# PortfolioHub DB Schema Specification
|
||||||
|
|
||||||
|
## 1. Purpose
|
||||||
|
|
||||||
|
This document defines the database schema in an AI-agent-friendly format.
|
||||||
|
|
||||||
|
- Source of truth: Alembic revisions in `migrations/versions`
|
||||||
|
- Current baseline revision: `20260403_0001`
|
||||||
|
- Baseline file: `migrations/versions/20260403_0001_initial_portfolio_schema.py`
|
||||||
|
|
||||||
|
## 2. Scope
|
||||||
|
|
||||||
|
Schema objects covered by baseline revision:
|
||||||
|
|
||||||
|
- `strategy_sets`
|
||||||
|
- `portfolios`
|
||||||
|
- `portfolio_ledger`
|
||||||
|
- secondary indexes
|
||||||
|
- Timescale hypertable conversion rule for `portfolio_ledger`
|
||||||
|
|
||||||
|
## 3. Conventions
|
||||||
|
|
||||||
|
- Data type names follow SQLAlchemy/Alembic declaration.
|
||||||
|
- `NULL` means nullable.
|
||||||
|
- Default means DB-level `server_default` where defined.
|
||||||
|
- Relationship columns are documented even when no FK constraint exists.
|
||||||
|
|
||||||
|
## 4. Tables
|
||||||
|
|
||||||
|
### 4.1 strategy_sets
|
||||||
|
|
||||||
|
Purpose:
|
||||||
|
|
||||||
|
- Stores normalized strategy configuration snapshots.
|
||||||
|
|
||||||
|
Columns:
|
||||||
|
|
||||||
|
| Column | Type | Null | Default | Constraints / Notes |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| id | Text | NO | - | Primary Key |
|
||||||
|
| strategy_name | Text | NO | - | Part of unique pair |
|
||||||
|
| strategy_config | Text | NO | "" | Part of unique pair |
|
||||||
|
| created_at | DateTime(timezone=True) | NO | NOW() | Creation timestamp |
|
||||||
|
|
||||||
|
Constraints:
|
||||||
|
|
||||||
|
- Primary key: `id`
|
||||||
|
- Unique: `uq_strategy_sets_name_config` on (`strategy_name`, `strategy_config`)
|
||||||
|
|
||||||
|
### 4.2 portfolios
|
||||||
|
|
||||||
|
Purpose:
|
||||||
|
|
||||||
|
- Stores current portfolio state and strategy linkage.
|
||||||
|
|
||||||
|
Columns:
|
||||||
|
|
||||||
|
| Column | Type | Null | Default | Constraints / Notes |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| portfolio_id | Text | NO | - | Primary Key |
|
||||||
|
| broker | Text | NO | "kis" | Broker code |
|
||||||
|
| status | Text | NO | "enabled" | Portfolio status |
|
||||||
|
| symbol | Text | NO | - | Trading symbol |
|
||||||
|
| strategy_name | Text | NO | "" | Strategy label |
|
||||||
|
| strategy_params | JSONB | NO | '{}'::jsonb | Strategy parameters |
|
||||||
|
| cash_balance | Float | NO | - | Current cash balance |
|
||||||
|
| asset_quantity | Float | NO | - | Current asset quantity |
|
||||||
|
| asset_price | Float | NO | 0 | Current price snapshot |
|
||||||
|
| profit_buffer | Float | NO | 0 | Profit buffer amount |
|
||||||
|
| current_strategy_set_id | Text | YES | - | Logical reference to `strategy_sets.id` (no FK) |
|
||||||
|
| created_at | DateTime(timezone=True) | NO | NOW() | Creation timestamp |
|
||||||
|
| updated_at | DateTime(timezone=True) | NO | NOW() | Last update timestamp |
|
||||||
|
| last_trade_at | DateTime(timezone=True) | YES | - | Last trade time |
|
||||||
|
|
||||||
|
Constraints:
|
||||||
|
|
||||||
|
- Primary key: `portfolio_id`
|
||||||
|
- Foreign key constraints: none in baseline
|
||||||
|
|
||||||
|
### 4.3 portfolio_ledger
|
||||||
|
|
||||||
|
Purpose:
|
||||||
|
|
||||||
|
- Stores append-like ledger snapshots per portfolio action.
|
||||||
|
|
||||||
|
Columns:
|
||||||
|
|
||||||
|
| Column | Type | Null | Default | Constraints / Notes |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| portfolio_id | Text | NO | - | Logical reference to `portfolios.portfolio_id` (no FK) |
|
||||||
|
| recorded_at | DateTime(timezone=True) | NO | - | Event timestamp |
|
||||||
|
| action | Text | NO | - | Action type |
|
||||||
|
| price | Float | NO | - | Trade/valuation unit price |
|
||||||
|
| quantity | Float | NO | - | Quantity delta |
|
||||||
|
| fee_amount | Float | NO | 0 | Fees |
|
||||||
|
| tax_amount | Float | NO | 0 | Taxes |
|
||||||
|
| cash_balance_after | Float | NO | - | Post-action cash |
|
||||||
|
| asset_quantity_after | Float | NO | - | Post-action asset quantity |
|
||||||
|
| total_value_after | Float | NO | - | Post-action total value |
|
||||||
|
| profit_buffer_after | Float | NO | 0 | Post-action profit buffer |
|
||||||
|
| strategy_set_id | Text | NO | "legacy" | Logical strategy set reference |
|
||||||
|
| note | Text | NO | "" | Operator/system note |
|
||||||
|
|
||||||
|
Constraints:
|
||||||
|
|
||||||
|
- Primary key: none in baseline
|
||||||
|
- Foreign key constraints: none in baseline
|
||||||
|
|
||||||
|
## 5. Indexes
|
||||||
|
|
||||||
|
### 5.1 idx_portfolio_ledger_portfolio_time
|
||||||
|
|
||||||
|
- Table: `portfolio_ledger`
|
||||||
|
- Columns/expressions: (`portfolio_id`, `recorded_at DESC`)
|
||||||
|
- Unique: no
|
||||||
|
- Intent: fetch recent ledger entries per portfolio efficiently.
|
||||||
|
|
||||||
|
### 5.2 idx_portfolios_broker_status_updated
|
||||||
|
|
||||||
|
- Table: `portfolios`
|
||||||
|
- Columns/expressions: (`broker`, `status`, `updated_at DESC`)
|
||||||
|
- Unique: no
|
||||||
|
- Intent: speed list/filter queries by broker/status and recency.
|
||||||
|
|
||||||
|
## 6. TimescaleDB Behavior
|
||||||
|
|
||||||
|
Conditional behavior in migration upgrade:
|
||||||
|
|
||||||
|
- Checks whether extension `timescaledb` exists.
|
||||||
|
- If present, executes:
|
||||||
|
|
||||||
|
`create_hypertable('portfolio_ledger', 'recorded_at', if_not_exists => TRUE)`
|
||||||
|
|
||||||
|
Implication:
|
||||||
|
|
||||||
|
- `portfolio_ledger` acts as a hypertable in environments with TimescaleDB extension enabled.
|
||||||
|
|
||||||
|
## 7. Integrity Notes
|
||||||
|
|
||||||
|
Current baseline intentionally has no DB-level FK constraints.
|
||||||
|
|
||||||
|
- Logical relations are managed by application/repository logic.
|
||||||
|
- When adding FK constraints in future migrations, update this document and include migration IDs.
|
||||||
|
|
||||||
|
## 8. Change Management Rule
|
||||||
|
|
||||||
|
When schema changes:
|
||||||
|
|
||||||
|
1. Add/modify Alembic revision under `migrations/versions`.
|
||||||
|
2. Update this document in the same commit.
|
||||||
|
3. Keep revision mapping table below in sync.
|
||||||
|
|
||||||
|
## 9. Revision Mapping
|
||||||
|
|
||||||
|
| Revision ID | File | Summary |
|
||||||
|
|---|---|---|
|
||||||
|
| 20260403_0001 | migrations/versions/20260403_0001_initial_portfolio_schema.py | Initial schema for strategy sets, portfolios, ledger, indexes, optional hypertable |
|
||||||
@@ -0,0 +1,380 @@
|
|||||||
|
# PortfolioHub Specification
|
||||||
|
|
||||||
|
## 1. Overview
|
||||||
|
|
||||||
|
PortfolioHub is a FastAPI-based portfolio lifecycle service with a Typer CLI.
|
||||||
|
|
||||||
|
- Runtime: Python 3.11+
|
||||||
|
- API framework: FastAPI
|
||||||
|
- CLI framework: Typer
|
||||||
|
- DB driver: Psycopg v3
|
||||||
|
- Migration: Alembic
|
||||||
|
|
||||||
|
Primary goals:
|
||||||
|
|
||||||
|
- Manage portfolio lifecycle (create/update/delete/reset)
|
||||||
|
- Record trade and balance-affecting actions (buy/sell/invest/transfer)
|
||||||
|
- Expose status and ledger snapshots through API and CLI
|
||||||
|
- Keep API and CLI behavior aligned through a shared service/repository layer
|
||||||
|
|
||||||
|
## 2. Scope and Non-Goals
|
||||||
|
|
||||||
|
In scope:
|
||||||
|
|
||||||
|
- Authenticated portfolio read and write operations
|
||||||
|
- Role-based access control (read, trade-write, admin)
|
||||||
|
- Optional PostgreSQL/Timescale-backed repository via feature flag
|
||||||
|
- Operational commands for DB bootstrap and migration
|
||||||
|
|
||||||
|
Current non-goals / limitations:
|
||||||
|
|
||||||
|
- In-memory fallback repository is not implemented for production usage
|
||||||
|
- External broker execution is not handled here (service focuses on portfolio state)
|
||||||
|
- Advanced observability wiring for Redis/Loki is not yet implemented in runtime paths
|
||||||
|
|
||||||
|
## 3. Architecture
|
||||||
|
|
||||||
|
Layered components:
|
||||||
|
|
||||||
|
- API layer: HTTP routes and request validation
|
||||||
|
- CLI layer: command parsing and output rendering
|
||||||
|
- Service layer: orchestration and response shaping
|
||||||
|
- Domain layer: business logic rules
|
||||||
|
- Repository layer: persistence abstraction and concrete Postgres implementation
|
||||||
|
|
||||||
|
Key design principle:
|
||||||
|
|
||||||
|
- API and CLI call the same `PortfolioService`, so behavior is consistent regardless of entrypoint.
|
||||||
|
|
||||||
|
## 4. Entrypoints
|
||||||
|
|
||||||
|
HTTP service:
|
||||||
|
|
||||||
|
- App factory: `app.main:create_app`
|
||||||
|
- ASGI app: `app.main:app`
|
||||||
|
- OpenAPI docs: `/docs`
|
||||||
|
- ReDoc: `/redoc`
|
||||||
|
|
||||||
|
CLI:
|
||||||
|
|
||||||
|
- Console script: `portfoliohub`
|
||||||
|
- Entry function: `app.cli.main:main`
|
||||||
|
|
||||||
|
## 5. Configuration Specification
|
||||||
|
|
||||||
|
Configuration precedence (high to low):
|
||||||
|
|
||||||
|
1. Environment variables
|
||||||
|
2. `.env` file (loaded at startup if present)
|
||||||
|
3. Code defaults
|
||||||
|
|
||||||
|
Core settings:
|
||||||
|
|
||||||
|
- `PORTFOLIOHUB_ALLOWED_ORIGINS`: comma-separated CORS origins
|
||||||
|
- `PORTFOLIOHUB_READ_TOKENS`: comma-separated read tokens
|
||||||
|
- `PORTFOLIOHUB_TRADE_WRITE_TOKENS`: comma-separated write tokens
|
||||||
|
- `PORTFOLIOHUB_ADMIN_TOKENS`: comma-separated admin tokens
|
||||||
|
- `PORTFOLIOHUB_DB_ENABLED`: enables DB repository selection when `true`
|
||||||
|
|
||||||
|
Database settings (active source):
|
||||||
|
|
||||||
|
- `TIMESCALEDB_HOST`
|
||||||
|
- `TIMESCALEDB_PORT`
|
||||||
|
- `TIMESCALEDB_NAME`
|
||||||
|
- `TIMESCALEDB_USER`
|
||||||
|
- `TIMESCALEDB_PASSWORD`
|
||||||
|
- `TIMESCALEDB_SSLMODE`
|
||||||
|
- `TIMESCALEDB_CONNECT_TIMEOUT_SEC`
|
||||||
|
- `TIMESCALEDB_APPLICATION_NAME`
|
||||||
|
|
||||||
|
Derived setting:
|
||||||
|
|
||||||
|
- `active_database_url` uses PostgreSQL psycopg URL format with encoded credentials and query options.
|
||||||
|
|
||||||
|
## 6. Authentication and Authorization
|
||||||
|
|
||||||
|
Auth scheme:
|
||||||
|
|
||||||
|
- `Authorization: Bearer <token>` required for protected API routes
|
||||||
|
|
||||||
|
Permission levels:
|
||||||
|
|
||||||
|
- `read`
|
||||||
|
- `trade-write`
|
||||||
|
- `admin`
|
||||||
|
|
||||||
|
Authorization hierarchy:
|
||||||
|
|
||||||
|
- `admin` can access all endpoints
|
||||||
|
- `trade-write` can access `trade-write` + `read`
|
||||||
|
- `read` can access only read endpoints
|
||||||
|
|
||||||
|
Failure responses:
|
||||||
|
|
||||||
|
- Missing/invalid bearer header: `401`
|
||||||
|
- Unknown token: `401`
|
||||||
|
- Insufficient permission: `403`
|
||||||
|
|
||||||
|
## 7. HTTP API Specification
|
||||||
|
|
||||||
|
Base routes:
|
||||||
|
|
||||||
|
- Health: `/health`
|
||||||
|
- Portfolio: `/portfolios`
|
||||||
|
|
||||||
|
### 7.1 Health Endpoint
|
||||||
|
|
||||||
|
- `GET /health`
|
||||||
|
- Auth: none
|
||||||
|
- Response `200`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"status": "ok"}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.2 Portfolio Endpoints
|
||||||
|
|
||||||
|
#### List Portfolios
|
||||||
|
|
||||||
|
- `GET /portfolios`
|
||||||
|
- Auth: `read`
|
||||||
|
- Query params:
|
||||||
|
- `broker` (optional string)
|
||||||
|
- `status_filter` (optional string)
|
||||||
|
- Response `200`: array of `PortfolioListItem`
|
||||||
|
|
||||||
|
#### Get Portfolio Status
|
||||||
|
|
||||||
|
- `GET /portfolios/{portfolio_id}`
|
||||||
|
- Auth: `read`
|
||||||
|
- Query params:
|
||||||
|
- `recent_trades` (int, default `5`)
|
||||||
|
- Response `200`: `PortfolioDetailResponse`
|
||||||
|
|
||||||
|
#### Create Portfolio
|
||||||
|
|
||||||
|
- `POST /portfolios`
|
||||||
|
- Auth: `trade-write`
|
||||||
|
- Body: `CreatePortfolioRequest`
|
||||||
|
- Response `201`: `PortfolioDetailResponse`
|
||||||
|
|
||||||
|
#### Update Portfolio
|
||||||
|
|
||||||
|
- `PATCH /portfolios/{portfolio_id}`
|
||||||
|
- Auth: `trade-write`
|
||||||
|
- Body: `UpdatePortfolioRequest`
|
||||||
|
- Response `200`: `PortfolioDetailResponse`
|
||||||
|
|
||||||
|
#### Buy
|
||||||
|
|
||||||
|
- `POST /portfolios/{portfolio_id}/buy`
|
||||||
|
- Auth: `trade-write`
|
||||||
|
- Body: `BuyRequest`
|
||||||
|
- Response `200`: `PortfolioDetailResponse`
|
||||||
|
|
||||||
|
#### Sell
|
||||||
|
|
||||||
|
- `POST /portfolios/{portfolio_id}/sell`
|
||||||
|
- Auth: `trade-write`
|
||||||
|
- Body: `SellRequest`
|
||||||
|
- Response `200`: `PortfolioDetailResponse`
|
||||||
|
|
||||||
|
#### Invest
|
||||||
|
|
||||||
|
- `POST /portfolios/{portfolio_id}/invest`
|
||||||
|
- Auth: `trade-write`
|
||||||
|
- Body: `InvestRequest`
|
||||||
|
- Response `200`: `PortfolioDetailResponse`
|
||||||
|
|
||||||
|
#### Transfer
|
||||||
|
|
||||||
|
- `POST /portfolios/{portfolio_id}/transfer`
|
||||||
|
- Auth: `trade-write`
|
||||||
|
- Body: `TransferRequest`
|
||||||
|
- Response `200`: `PortfolioDetailResponse`
|
||||||
|
|
||||||
|
#### Reset
|
||||||
|
|
||||||
|
- `POST /portfolios/{portfolio_id}/reset`
|
||||||
|
- Auth: `trade-write`
|
||||||
|
- Body: `ResetPortfolioRequest`
|
||||||
|
- Response `200`: `PortfolioDetailResponse`
|
||||||
|
|
||||||
|
#### Delete
|
||||||
|
|
||||||
|
- `DELETE /portfolios/{portfolio_id}`
|
||||||
|
- Auth: `admin`
|
||||||
|
- Query params:
|
||||||
|
- `keep_ledger` (bool, default `false`)
|
||||||
|
- Response `204`: no content
|
||||||
|
|
||||||
|
### 7.3 Example Requests and Responses
|
||||||
|
|
||||||
|
- See `docs/api-examples.md` for maintained request/response examples.
|
||||||
|
|
||||||
|
## 8. Schema Specification
|
||||||
|
|
||||||
|
### 8.1 Request Models
|
||||||
|
|
||||||
|
`CreatePortfolioRequest`
|
||||||
|
|
||||||
|
- `portfolio_id: str`
|
||||||
|
- `broker: str = "kis"`
|
||||||
|
- `status: str = "enabled"`
|
||||||
|
- `symbol: str`
|
||||||
|
- `strategy_name: str = "target_value"`
|
||||||
|
- `strategy_params: object = {}`
|
||||||
|
- `cash_balance: float >= 0`
|
||||||
|
- `asset_quantity: float >= 0`
|
||||||
|
- `asset_price: float >= 0`
|
||||||
|
- `profit_buffer: float >= 0`
|
||||||
|
|
||||||
|
`UpdatePortfolioRequest` (all fields optional)
|
||||||
|
|
||||||
|
- `broker: str | null`
|
||||||
|
- `status: str | null`
|
||||||
|
- `symbol: str | null`
|
||||||
|
- `strategy_name: str | null`
|
||||||
|
- `strategy_params: object | null`
|
||||||
|
- `asset_price: float >= 0 | null`
|
||||||
|
|
||||||
|
`BuyRequest` / `SellRequest`
|
||||||
|
|
||||||
|
- `quantity: float > 0`
|
||||||
|
- `price: float > 0`
|
||||||
|
- `fee: float >= 0`
|
||||||
|
- `tax: float >= 0`
|
||||||
|
- `note: str = ""`
|
||||||
|
|
||||||
|
`InvestRequest`
|
||||||
|
|
||||||
|
- `movement_type: "deposit" | "profit" | "withdraw"`
|
||||||
|
- `amount: float > 0`
|
||||||
|
- `note: str = ""`
|
||||||
|
|
||||||
|
`TransferRequest`
|
||||||
|
|
||||||
|
- `direction: "to-buffer" | "from-buffer"`
|
||||||
|
- `amount: float > 0`
|
||||||
|
- `note: str = ""`
|
||||||
|
|
||||||
|
`ResetPortfolioRequest`
|
||||||
|
|
||||||
|
- `infer_symbol: str | null`
|
||||||
|
|
||||||
|
### 8.2 Response Models
|
||||||
|
|
||||||
|
`PortfolioListItem`
|
||||||
|
|
||||||
|
- Includes portfolio summary fields and computed `total_value`
|
||||||
|
|
||||||
|
`PortfolioDetailResponse`
|
||||||
|
|
||||||
|
- `portfolio: PortfolioSummary`
|
||||||
|
- `recent_entries: LedgerEntryResponse[]`
|
||||||
|
- `strategy_set: StrategySetResponse | null`
|
||||||
|
- `live_quantity: float | null`
|
||||||
|
|
||||||
|
`LedgerEntryResponse`
|
||||||
|
|
||||||
|
- Snapshot-oriented ledger response including balances after each action
|
||||||
|
|
||||||
|
`ErrorResponse`
|
||||||
|
|
||||||
|
- `detail: str`
|
||||||
|
|
||||||
|
## 9. CLI Specification
|
||||||
|
|
||||||
|
Global option:
|
||||||
|
|
||||||
|
- `--format [table|json]` (default: `table`)
|
||||||
|
|
||||||
|
Commands:
|
||||||
|
|
||||||
|
- `portfoliohub init-db [--maintenance-db postgres]`
|
||||||
|
- `portfoliohub migrate-db`
|
||||||
|
- `portfoliohub health-check`
|
||||||
|
|
||||||
|
Portfolio subcommands:
|
||||||
|
|
||||||
|
- `portfoliohub portfolio list [--broker ...] [--status ...]`
|
||||||
|
- `portfoliohub portfolio status <portfolio_id> [--recent 5]`
|
||||||
|
- `portfoliohub portfolio create <portfolio_id> --symbol ... [options] [--force]`
|
||||||
|
- `portfoliohub portfolio update <portfolio_id> [options] [--force]`
|
||||||
|
- `portfoliohub portfolio buy <portfolio_id> --quantity ... --price ... [options] [--force]`
|
||||||
|
- `portfoliohub portfolio sell <portfolio_id> --quantity ... --price ... [options] [--force]`
|
||||||
|
- `portfoliohub portfolio invest <portfolio_id> --type <deposit|profit|withdraw> --amount ... [options] [--force]`
|
||||||
|
- `portfoliohub portfolio transfer <portfolio_id> --direction <to-buffer|from-buffer> --amount ... [options] [--force]`
|
||||||
|
- `portfoliohub portfolio reset <portfolio_id> [--infer-symbol ...] [--force]`
|
||||||
|
- `portfoliohub portfolio delete <portfolio_id> [--keep-ledger] [--force]`
|
||||||
|
|
||||||
|
Confirmation behavior:
|
||||||
|
|
||||||
|
- Mutating portfolio commands request confirmation by default.
|
||||||
|
- `--force` bypasses confirmation prompts.
|
||||||
|
|
||||||
|
## 10. Persistence and Migration Specification
|
||||||
|
|
||||||
|
Repository selection:
|
||||||
|
|
||||||
|
- If `PORTFOLIOHUB_DB_ENABLED=false`: an unimplemented placeholder repository raises `FeatureNotReadyError` on access.
|
||||||
|
- If `PORTFOLIOHUB_DB_ENABLED=true`: `PostgresPortfolioRepository` is used.
|
||||||
|
|
||||||
|
Migration commands:
|
||||||
|
|
||||||
|
- `init-db`: create target DB if missing, then apply Alembic `head`
|
||||||
|
- `migrate-db`: apply Alembic `head` to existing target DB
|
||||||
|
|
||||||
|
Migration source of truth:
|
||||||
|
|
||||||
|
- `migrations/versions`
|
||||||
|
|
||||||
|
DB schema specification:
|
||||||
|
|
||||||
|
- Detailed table/column/index constraints are documented in `docs/db-schema-spec.md`.
|
||||||
|
- The documentation is generated from and must stay consistent with Alembic revisions under `migrations/versions`.
|
||||||
|
|
||||||
|
## 11. Error Handling
|
||||||
|
|
||||||
|
Application-level errors are translated through registered exception handlers.
|
||||||
|
|
||||||
|
Typical error classes include:
|
||||||
|
|
||||||
|
- authorization failures (`401`, `403`)
|
||||||
|
- domain/repository-level failures surfaced by service operations
|
||||||
|
- feature-not-ready errors when DB mode is disabled but persistence is required
|
||||||
|
|
||||||
|
## 12. CORS Policy
|
||||||
|
|
||||||
|
CORS middleware is enabled with:
|
||||||
|
|
||||||
|
- `allow_origins` from configured origins list
|
||||||
|
- `allow_credentials = true`
|
||||||
|
- `allow_methods = ["GET", "POST", "PATCH", "DELETE", "OPTIONS"]`
|
||||||
|
- `allow_headers = ["Authorization", "Content-Type"]`
|
||||||
|
|
||||||
|
## 13. Testing Specification
|
||||||
|
|
||||||
|
Test framework:
|
||||||
|
|
||||||
|
- `pytest`
|
||||||
|
|
||||||
|
Configured behavior:
|
||||||
|
|
||||||
|
- test paths: `tests`
|
||||||
|
- recursion excluded: `temp`, `.git`, `.venv`
|
||||||
|
|
||||||
|
Current test modules include API, auth, CLI, and domain service coverage.
|
||||||
|
|
||||||
|
## 14. Versioning and Compatibility
|
||||||
|
|
||||||
|
- Current package version: `0.1.0`
|
||||||
|
- API version exposure follows application version setting (`app_version`)
|
||||||
|
- Minimum Python version: `3.11`
|
||||||
|
|
||||||
|
## 15. Future Extension Points
|
||||||
|
|
||||||
|
- Redis-backed cache/session integration
|
||||||
|
- Loki logging pipeline integration
|
||||||
|
- Additional brokers and order-execution adapters
|
||||||
|
- Enhanced operational/admin endpoints
|
||||||
@@ -25,6 +25,7 @@ portfoliohub = "app.cli.main:main"
|
|||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
dev = [
|
dev = [
|
||||||
"httpx>=0.27.0,<1.0.0",
|
"httpx>=0.27.0,<1.0.0",
|
||||||
|
"pre-commit>=3.8.0,<4.0.0",
|
||||||
"pytest>=8.3.0,<9.0.0",
|
"pytest>=8.3.0,<9.0.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,177 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Guardrail check for implementation-to-spec synchronization.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- If files under app/ change, docs/portfoliohub-spec.md must be part of the same change set.
|
||||||
|
- If files under app/api/ or app/schemas/ change, docs/api-examples.md must be part of the same change set.
|
||||||
|
- If files under migrations/versions/ change, docs/db-schema-spec.md must be part of the same change set.
|
||||||
|
- If migrations/versions/ files change, revision IDs found in changed migration files
|
||||||
|
must appear in docs/db-schema-spec.md.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
GENERAL_SPEC = "docs/portfoliohub-spec.md"
|
||||||
|
DB_SPEC = "docs/db-schema-spec.md"
|
||||||
|
API_EXAMPLES_SPEC = "docs/api-examples.md"
|
||||||
|
APP_PREFIX = "app/"
|
||||||
|
API_PREFIXES = ("app/api/", "app/schemas/")
|
||||||
|
MIGRATION_PREFIX = "migrations/versions/"
|
||||||
|
|
||||||
|
|
||||||
|
class CheckError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _run_git(args: list[str]) -> str:
|
||||||
|
result = subprocess.run(
|
||||||
|
["git", *args],
|
||||||
|
cwd=ROOT,
|
||||||
|
check=True,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
return result.stdout
|
||||||
|
|
||||||
|
|
||||||
|
def _ref_exists(ref: str) -> bool:
|
||||||
|
result = subprocess.run(
|
||||||
|
["git", "rev-parse", "--verify", "--quiet", ref],
|
||||||
|
cwd=ROOT,
|
||||||
|
check=False,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
return result.returncode == 0
|
||||||
|
|
||||||
|
|
||||||
|
def _get_changed_files(*, staged: bool, against: str | None, head: str) -> list[str]:
|
||||||
|
if staged:
|
||||||
|
output = _run_git(["diff", "--name-only", "--cached", "--diff-filter=ACMR"])
|
||||||
|
elif against:
|
||||||
|
if not _ref_exists(against):
|
||||||
|
raise CheckError(f"base ref not found: {against}")
|
||||||
|
if not _ref_exists(head):
|
||||||
|
raise CheckError(f"head ref not found: {head}")
|
||||||
|
output = _run_git(["diff", "--name-only", "--diff-filter=ACMR", f"{against}...{head}"])
|
||||||
|
else:
|
||||||
|
if not _ref_exists("HEAD~1"):
|
||||||
|
# For a repository with a single commit, there is nothing to compare against.
|
||||||
|
return []
|
||||||
|
output = _run_git(["diff", "--name-only", "--diff-filter=ACMR", "HEAD~1", "HEAD"])
|
||||||
|
return [line.strip() for line in output.splitlines() if line.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_revision_id(path: Path) -> str | None:
|
||||||
|
text = path.read_text(encoding="utf-8")
|
||||||
|
match = re.search(r'^revision\s*=\s*["\']([^"\']+)["\']', text, flags=re.MULTILINE)
|
||||||
|
return match.group(1) if match else None
|
||||||
|
|
||||||
|
|
||||||
|
def _collect_changed_revision_ids(changed_files: list[str]) -> list[str]:
|
||||||
|
revision_ids: list[str] = []
|
||||||
|
for file in changed_files:
|
||||||
|
if not file.startswith(MIGRATION_PREFIX) or not file.endswith(".py"):
|
||||||
|
continue
|
||||||
|
migration_path = ROOT / file
|
||||||
|
if not migration_path.exists():
|
||||||
|
continue
|
||||||
|
revision_id = _extract_revision_id(migration_path)
|
||||||
|
if revision_id:
|
||||||
|
revision_ids.append(revision_id)
|
||||||
|
return revision_ids
|
||||||
|
|
||||||
|
|
||||||
|
def run_check(*, staged: bool, against: str | None, head: str) -> int:
|
||||||
|
try:
|
||||||
|
changed_files = _get_changed_files(staged=staged, against=against, head=head)
|
||||||
|
except CheckError as exc:
|
||||||
|
print(f"docs-sync-check: {exc}")
|
||||||
|
return 2
|
||||||
|
except subprocess.CalledProcessError as exc:
|
||||||
|
print("docs-sync-check: failed to read git diff")
|
||||||
|
print(exc.stderr.strip())
|
||||||
|
return 2
|
||||||
|
|
||||||
|
if not changed_files:
|
||||||
|
print("docs-sync-check: no changes detected")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
needs_general_spec = any(path.startswith(APP_PREFIX) for path in changed_files)
|
||||||
|
needs_api_examples = any(path.startswith(prefix) for prefix in API_PREFIXES for path in changed_files)
|
||||||
|
needs_db_spec = any(path.startswith(MIGRATION_PREFIX) for path in changed_files)
|
||||||
|
|
||||||
|
errors: list[str] = []
|
||||||
|
|
||||||
|
if needs_general_spec and GENERAL_SPEC not in changed_files:
|
||||||
|
errors.append(
|
||||||
|
"Application code changed but docs/portfoliohub-spec.md was not updated in the same commit/PR."
|
||||||
|
)
|
||||||
|
|
||||||
|
if needs_api_examples and API_EXAMPLES_SPEC not in changed_files:
|
||||||
|
errors.append(
|
||||||
|
"API/schema code changed but docs/api-examples.md was not updated in the same commit/PR."
|
||||||
|
)
|
||||||
|
|
||||||
|
if needs_db_spec and DB_SPEC not in changed_files:
|
||||||
|
errors.append(
|
||||||
|
"Migration changed but docs/db-schema-spec.md was not updated in the same commit/PR."
|
||||||
|
)
|
||||||
|
|
||||||
|
if needs_db_spec:
|
||||||
|
db_spec_path = ROOT / DB_SPEC
|
||||||
|
if not db_spec_path.exists():
|
||||||
|
errors.append("docs/db-schema-spec.md is missing.")
|
||||||
|
else:
|
||||||
|
db_spec_text = db_spec_path.read_text(encoding="utf-8")
|
||||||
|
changed_revisions = _collect_changed_revision_ids(changed_files)
|
||||||
|
missing_revisions = [rev for rev in changed_revisions if rev not in db_spec_text]
|
||||||
|
if missing_revisions:
|
||||||
|
errors.append(
|
||||||
|
"Migration revision IDs missing in docs/db-schema-spec.md: "
|
||||||
|
+ ", ".join(sorted(set(missing_revisions)))
|
||||||
|
)
|
||||||
|
|
||||||
|
if errors:
|
||||||
|
print("docs-sync-check: FAILED")
|
||||||
|
for error in errors:
|
||||||
|
print(f"- {error}")
|
||||||
|
print("\nChanged files:")
|
||||||
|
for file in changed_files:
|
||||||
|
print(f"- {file}")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
print("docs-sync-check: OK")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args() -> argparse.Namespace:
|
||||||
|
parser = argparse.ArgumentParser(description="Verify implementation and docs stay in sync.")
|
||||||
|
parser.add_argument(
|
||||||
|
"--staged",
|
||||||
|
action="store_true",
|
||||||
|
help="Check staged files (for pre-commit).",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--against",
|
||||||
|
default=None,
|
||||||
|
help="Git base ref/sha to diff against (for CI).",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--head",
|
||||||
|
default="HEAD",
|
||||||
|
help="Git head ref/sha used with --against.",
|
||||||
|
)
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
args = parse_args()
|
||||||
|
sys.exit(run_check(staged=args.staged, against=args.against, head=args.head))
|
||||||
Reference in New Issue
Block a user