DhanHQ 3.0.0 → 3.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.rubocop.yml +2 -0
- data/.rubocop_todo.yml +7 -0
- data/CHANGELOG.md +51 -0
- data/README.md +152 -4
- data/docs/CONSTANTS_REFERENCE.md +3 -2
- data/exe/dhanhq-mcp +7 -0
- data/lib/DhanHQ/agent/order_preview.rb +50 -0
- data/lib/DhanHQ/agent/policy.rb +51 -0
- data/lib/DhanHQ/agent/tool_registry.rb +299 -0
- data/lib/DhanHQ/agent.rb +12 -0
- data/lib/DhanHQ/ai/context_builder.rb +145 -0
- data/lib/DhanHQ/ai/prompt_helpers.rb +114 -0
- data/lib/DhanHQ/ai.rb +27 -0
- data/lib/DhanHQ/auth.rb +0 -1
- data/lib/DhanHQ/client.rb +1 -3
- data/lib/DhanHQ/concerns/order_audit.rb +43 -1
- data/lib/DhanHQ/constants.rb +5 -2
- data/lib/DhanHQ/contracts/forever_order_contract.rb +1 -1
- data/lib/DhanHQ/contracts/iceberg_order_contract.rb +83 -0
- data/lib/DhanHQ/contracts/place_order_contract.rb +1 -1
- data/lib/DhanHQ/contracts/twap_order_contract.rb +106 -0
- data/lib/DhanHQ/core/auth_api.rb +0 -1
- data/lib/DhanHQ/errors.rb +4 -0
- data/lib/DhanHQ/events/base.rb +203 -0
- data/lib/DhanHQ/events/bus.rb +158 -0
- data/lib/DhanHQ/events.rb +40 -0
- data/lib/DhanHQ/indicators.rb +283 -0
- data/lib/DhanHQ/market_data/market_snapshot.rb +97 -0
- data/lib/DhanHQ/market_data/ohlc_series.rb +169 -0
- data/lib/DhanHQ/market_data/option_snapshot.rb +223 -0
- data/lib/DhanHQ/market_data.rb +25 -0
- data/lib/DhanHQ/mcp/server.rb +235 -0
- data/lib/DhanHQ/mcp.rb +10 -0
- data/lib/DhanHQ/models/funds.rb +12 -0
- data/lib/DhanHQ/models/holding.rb +42 -0
- data/lib/DhanHQ/models/iceberg_order.rb +139 -0
- data/lib/DhanHQ/models/instrument.rb +80 -14
- data/lib/DhanHQ/models/order.rb +95 -0
- data/lib/DhanHQ/models/position.rb +66 -0
- data/lib/DhanHQ/models/search_result.rb +12 -0
- data/lib/DhanHQ/models/trade.rb +13 -0
- data/lib/DhanHQ/models/twap_order.rb +136 -0
- data/lib/DhanHQ/option_analytics/black_scholes.rb +194 -0
- data/lib/DhanHQ/option_analytics/max_pain.rb +119 -0
- data/lib/DhanHQ/option_analytics.rb +36 -0
- data/lib/DhanHQ/rate_limiter.rb +5 -3
- data/lib/DhanHQ/resources/alert_orders.rb +1 -0
- data/lib/DhanHQ/resources/forever_orders.rb +1 -0
- data/lib/DhanHQ/resources/iceberg_orders.rb +62 -0
- data/lib/DhanHQ/resources/orders.rb +2 -0
- data/lib/DhanHQ/resources/pnl_exit.rb +1 -0
- data/lib/DhanHQ/resources/super_orders.rb +1 -0
- data/lib/DhanHQ/resources/twap_orders.rb +62 -0
- data/lib/DhanHQ/risk/checks/asm_gsm.rb +17 -0
- data/lib/DhanHQ/risk/checks/concentration.rb +37 -0
- data/lib/DhanHQ/risk/checks/market_hours.rb +37 -0
- data/lib/DhanHQ/risk/checks/max_loss.rb +24 -0
- data/lib/DhanHQ/risk/checks/options.rb +46 -0
- data/lib/DhanHQ/risk/checks/order_type.rb +20 -0
- data/lib/DhanHQ/risk/checks/position_limits.rb +24 -0
- data/lib/DhanHQ/risk/checks/product_support.rb +34 -0
- data/lib/DhanHQ/risk/checks/quantity.rb +32 -0
- data/lib/DhanHQ/risk/checks/trading_permission.rb +16 -0
- data/lib/DhanHQ/risk/pipeline.rb +72 -0
- data/lib/DhanHQ/risk.rb +250 -0
- data/lib/DhanHQ/skills/base.rb +183 -0
- data/lib/DhanHQ/skills/builtin/bear_call_spread.rb +87 -0
- data/lib/DhanHQ/skills/builtin/bull_put_spread.rb +87 -0
- data/lib/DhanHQ/skills/builtin/buy_atm_call.rb +84 -0
- data/lib/DhanHQ/skills/builtin/covered_call.rb +85 -0
- data/lib/DhanHQ/skills/builtin/iron_condor.rb +89 -0
- data/lib/DhanHQ/skills/builtin/market_data_summarizer.rb +195 -0
- data/lib/DhanHQ/skills/builtin/protective_put.rb +90 -0
- data/lib/DhanHQ/skills/builtin/square_off_all.rb +42 -0
- data/lib/DhanHQ/skills/builtin/square_off_position.rb +50 -0
- data/lib/DhanHQ/skills/builtin/straddle.rb +88 -0
- data/lib/DhanHQ/skills/builtin/strangle.rb +93 -0
- data/lib/DhanHQ/skills/registry.rb +101 -0
- data/lib/DhanHQ/skills/workflow.rb +66 -0
- data/lib/DhanHQ/skills.rb +29 -0
- data/lib/DhanHQ/strategy/base.rb +189 -0
- data/lib/DhanHQ/strategy.rb +40 -0
- data/lib/DhanHQ/version.rb +1 -1
- data/lib/DhanHQ/ws/decoder.rb +57 -19
- data/lib/DhanHQ.rb +3 -0
- data/lib/dhan_hq/agent.rb +3 -0
- data/lib/dhan_hq/mcp.rb +3 -0
- data/lib/dhan_hq.rb +74 -2
- data/lib/ta/technical_analysis.rb +3 -1
- data/skills/dhanhq-ruby/SKILL.md +207 -0
- data/skills/dhanhq-ruby/examples/fetch_option_chain.rb +54 -0
- data/skills/dhanhq-ruby/examples/gtt_forever_order.rb +65 -0
- data/skills/dhanhq-ruby/examples/historical_data_analysis.rb +89 -0
- data/skills/dhanhq-ruby/examples/iron_condor.rb +137 -0
- data/skills/dhanhq-ruby/examples/live_feed_setup.rb +43 -0
- data/skills/dhanhq-ruby/examples/margin_check.rb +42 -0
- data/skills/dhanhq-ruby/examples/order_management.rb +105 -0
- data/skills/dhanhq-ruby/examples/place_equity_order.rb +36 -0
- data/skills/dhanhq-ruby/examples/place_fno_order.rb +76 -0
- data/skills/dhanhq-ruby/examples/portfolio_summary.rb +74 -0
- data/skills/dhanhq-ruby/examples/super_order_with_sl.rb +57 -0
- data/skills/dhanhq-ruby/references/backtesting-with-dhan.md +65 -0
- data/skills/dhanhq-ruby/references/common-workflows.md +76 -0
- data/skills/dhanhq-ruby/references/error-codes.md +50 -0
- data/skills/dhanhq-ruby/references/funds.md +67 -0
- data/skills/dhanhq-ruby/references/instruments.md +85 -0
- data/skills/dhanhq-ruby/references/live-feed.md +83 -0
- data/skills/dhanhq-ruby/references/market-data.md +119 -0
- data/skills/dhanhq-ruby/references/option-chain.md +71 -0
- data/skills/dhanhq-ruby/references/options-analysis-patterns.md +76 -0
- data/skills/dhanhq-ruby/references/orders.md +201 -0
- data/skills/dhanhq-ruby/references/portfolio.md +93 -0
- data/skills/dhanhq-ruby/references/scanx-data.md +62 -0
- data/skills/dhanhq-ruby/scripts/dhan_helpers.rb +323 -0
- data/skills/dhanhq-ruby/scripts/resolve_security.rb +168 -0
- data/skills/dhanhq-ruby/scripts/trade_logger.rb +131 -0
- data/skills/dhanhq-ruby/scripts/validate_order.rb +169 -0
- metadata +95 -20
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DhanHQ
|
|
4
|
+
module Risk
|
|
5
|
+
module Checks
|
|
6
|
+
# Enforces option-specific risk rules (index-only, stop loss, target, risk-reward).
|
|
7
|
+
class Options
|
|
8
|
+
def self.run!(args:, instrument:, **_unused)
|
|
9
|
+
enforce_index!(instrument)
|
|
10
|
+
enforce_stop_loss!(args)
|
|
11
|
+
enforce_target!(args)
|
|
12
|
+
enforce_risk_reward!(args)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def self.enforce_index!(instrument)
|
|
16
|
+
return if instrument.instrument_type == "INDEX"
|
|
17
|
+
|
|
18
|
+
raise DhanHQ::RiskViolation, "Options only allowed on index"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def self.enforce_stop_loss!(args)
|
|
22
|
+
return if args["stop_loss"]
|
|
23
|
+
|
|
24
|
+
raise DhanHQ::RiskViolation, "Stop loss required"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def self.enforce_target!(args)
|
|
28
|
+
return if args["target"]
|
|
29
|
+
|
|
30
|
+
raise DhanHQ::RiskViolation, "Target required"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def self.enforce_risk_reward!(args)
|
|
34
|
+
stop_loss = args["stop_loss"].to_f
|
|
35
|
+
target = args["target"].to_f
|
|
36
|
+
return if target > stop_loss
|
|
37
|
+
|
|
38
|
+
raise DhanHQ::RiskViolation, "Invalid risk-reward"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private_class_method :enforce_index!, :enforce_stop_loss!,
|
|
42
|
+
:enforce_target!, :enforce_risk_reward!
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DhanHQ
|
|
4
|
+
module Risk
|
|
5
|
+
module Checks
|
|
6
|
+
# Restricts order types to MARKET and LIMIT only.
|
|
7
|
+
class OrderType
|
|
8
|
+
VALID_TYPES = %w[MARKET LIMIT].freeze
|
|
9
|
+
|
|
10
|
+
def self.run!(args:, **_unused)
|
|
11
|
+
order_type = args["order_type"]
|
|
12
|
+
return unless order_type
|
|
13
|
+
return if VALID_TYPES.include?(order_type)
|
|
14
|
+
|
|
15
|
+
raise DhanHQ::RiskViolation, "Invalid order type"
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DhanHQ
|
|
4
|
+
module Risk
|
|
5
|
+
module Checks
|
|
6
|
+
# Enforces maximum number of concurrent open positions.
|
|
7
|
+
class PositionLimits
|
|
8
|
+
MAX_OPEN_POSITIONS = 20
|
|
9
|
+
|
|
10
|
+
def self.run!(**_unused)
|
|
11
|
+
positions = DhanHQ::Models::Position.all
|
|
12
|
+
open_count = positions.count do |p|
|
|
13
|
+
p.net_qty.to_i != 0
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
return if open_count < MAX_OPEN_POSITIONS
|
|
17
|
+
|
|
18
|
+
raise DhanHQ::RiskViolation,
|
|
19
|
+
"Maximum #{MAX_OPEN_POSITIONS} open positions exceeded (#{open_count} open)"
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DhanHQ
|
|
4
|
+
module Risk
|
|
5
|
+
module Checks
|
|
6
|
+
# Validates that the instrument supports the requested product type (BO/CO).
|
|
7
|
+
class ProductSupport
|
|
8
|
+
def self.run!(args:, instrument:, **_unused)
|
|
9
|
+
product_type = args["product_type"]
|
|
10
|
+
return unless product_type
|
|
11
|
+
|
|
12
|
+
enforce_bracket_support!(product_type, instrument)
|
|
13
|
+
enforce_cover_support!(product_type, instrument)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def self.enforce_bracket_support!(product_type, instrument)
|
|
17
|
+
return unless product_type == DhanHQ::Constants::ProductType::BO
|
|
18
|
+
return if instrument.bracket_flag == "Y"
|
|
19
|
+
|
|
20
|
+
raise DhanHQ::RiskViolation, "Bracket orders not supported"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def self.enforce_cover_support!(product_type, instrument)
|
|
24
|
+
return unless product_type == DhanHQ::Constants::ProductType::CO
|
|
25
|
+
return if instrument.cover_flag == "Y"
|
|
26
|
+
|
|
27
|
+
raise DhanHQ::RiskViolation, "Cover orders not supported"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private_class_method :enforce_bracket_support!, :enforce_cover_support!
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DhanHQ
|
|
4
|
+
module Risk
|
|
5
|
+
module Checks
|
|
6
|
+
# Enforces quantity and notional limits for MCP tool safety.
|
|
7
|
+
class Quantity
|
|
8
|
+
MAX_QUANTITY = 10
|
|
9
|
+
MAX_NOTIONAL = 100_000
|
|
10
|
+
|
|
11
|
+
def self.run!(args:, **_unused)
|
|
12
|
+
quantity = args["quantity"].to_i
|
|
13
|
+
raise DhanHQ::RiskViolation, "Quantity must be > 0" unless quantity.positive?
|
|
14
|
+
raise DhanHQ::RiskViolation, "Quantity exceeds limit" if quantity > MAX_QUANTITY
|
|
15
|
+
|
|
16
|
+
enforce_notional_limit!(quantity, args["price"])
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def self.enforce_notional_limit!(quantity, price)
|
|
20
|
+
return unless price
|
|
21
|
+
|
|
22
|
+
notional = quantity * price.to_f
|
|
23
|
+
return if notional <= MAX_NOTIONAL
|
|
24
|
+
|
|
25
|
+
raise DhanHQ::RiskViolation, "Notional exceeds limit"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private_class_method :enforce_notional_limit!
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DhanHQ
|
|
4
|
+
module Risk
|
|
5
|
+
module Checks
|
|
6
|
+
# Blocks trading on instruments where buy_sell_indicator is not "A".
|
|
7
|
+
class TradingPermission
|
|
8
|
+
def self.run!(instrument:, **_unused)
|
|
9
|
+
return if instrument.buy_sell_indicator == "A"
|
|
10
|
+
|
|
11
|
+
raise DhanHQ::RiskViolation, "Trading disabled for instrument"
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DhanHQ
|
|
4
|
+
module Risk
|
|
5
|
+
# Pre-execution risk pipeline that validates orders before they reach the broker.
|
|
6
|
+
#
|
|
7
|
+
# Runs a sequence of checks against the instrument and order arguments.
|
|
8
|
+
# Raises DhanHQ::RiskViolation on the first failure.
|
|
9
|
+
#
|
|
10
|
+
# @example Run equity risk checks
|
|
11
|
+
# DhanHQ::Risk::Pipeline.run!(
|
|
12
|
+
# instrument: instrument,
|
|
13
|
+
# args: args,
|
|
14
|
+
# now: Time.now,
|
|
15
|
+
# type: :equity
|
|
16
|
+
# )
|
|
17
|
+
#
|
|
18
|
+
# @example Run options risk checks
|
|
19
|
+
# DhanHQ::Risk::Pipeline.run!(
|
|
20
|
+
# instrument: instrument,
|
|
21
|
+
# args: args,
|
|
22
|
+
# now: Time.now,
|
|
23
|
+
# type: :options
|
|
24
|
+
# )
|
|
25
|
+
#
|
|
26
|
+
class Pipeline
|
|
27
|
+
CHECKS = [
|
|
28
|
+
Checks::TradingPermission,
|
|
29
|
+
Checks::AsmGsm,
|
|
30
|
+
Checks::ProductSupport,
|
|
31
|
+
Checks::OrderType,
|
|
32
|
+
Checks::Quantity,
|
|
33
|
+
Checks::MarketHours,
|
|
34
|
+
Checks::PositionLimits,
|
|
35
|
+
Checks::Concentration
|
|
36
|
+
].freeze
|
|
37
|
+
|
|
38
|
+
OPTION_CHECKS = [
|
|
39
|
+
Checks::Options
|
|
40
|
+
].freeze
|
|
41
|
+
|
|
42
|
+
DAILY_CHECKS = [
|
|
43
|
+
Checks::MaxLoss
|
|
44
|
+
].freeze
|
|
45
|
+
|
|
46
|
+
# Run all applicable risk checks.
|
|
47
|
+
#
|
|
48
|
+
# @param instrument [Object] instrument with trading metadata
|
|
49
|
+
# @param args [Hash] order arguments (string keys)
|
|
50
|
+
# @param now [Time] current time for market hours check (default: Time.now)
|
|
51
|
+
# @param type [Symbol] :equity or :options
|
|
52
|
+
# @return [true] if all checks pass
|
|
53
|
+
# @raise [DhanHQ::RiskViolation] on first failure
|
|
54
|
+
# rubocop:disable Naming/PredicateMethod
|
|
55
|
+
def self.run!(instrument:, args:, now: Time.now, type: :equity)
|
|
56
|
+
run_checks!(CHECKS, instrument, args, now)
|
|
57
|
+
run_checks!(OPTION_CHECKS, instrument, args, now) if type == :options
|
|
58
|
+
run_checks!(DAILY_CHECKS, instrument, args, now)
|
|
59
|
+
true
|
|
60
|
+
end
|
|
61
|
+
# rubocop:enable Naming/PredicateMethod
|
|
62
|
+
|
|
63
|
+
def self.run_checks!(checks, instrument, args, now)
|
|
64
|
+
checks.each do |check|
|
|
65
|
+
check.run!(instrument: instrument, args: args, now: now)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private_class_method :run_checks!
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
data/lib/DhanHQ/risk.rb
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DhanHQ
|
|
4
|
+
# Risk management utilities for position sizing and order risk calculation.
|
|
5
|
+
module Risk
|
|
6
|
+
# Calculate optimal position size based on risk parameters.
|
|
7
|
+
#
|
|
8
|
+
# Supports multiple sizing methods:
|
|
9
|
+
# - Fixed risk percentage of account
|
|
10
|
+
# - Kelly criterion
|
|
11
|
+
# - Volatility-based sizing
|
|
12
|
+
#
|
|
13
|
+
# @example Calculate position size with 2% risk
|
|
14
|
+
# size = DhanHQ::Risk::PositionSizer.calculate(
|
|
15
|
+
# account_balance: 100_000,
|
|
16
|
+
# risk_percent: 2.0,
|
|
17
|
+
# entry_price: 2500,
|
|
18
|
+
# stop_loss_price: 2450
|
|
19
|
+
# )
|
|
20
|
+
# #=> 40
|
|
21
|
+
#
|
|
22
|
+
class PositionSizer
|
|
23
|
+
# Calculate position size based on fixed risk percentage.
|
|
24
|
+
#
|
|
25
|
+
# @param account_balance [Float] Total account balance
|
|
26
|
+
# @param risk_percent [Float] Percentage of account to risk per trade (e.g., 2.0 for 2%)
|
|
27
|
+
# @param entry_price [Float] Planned entry price
|
|
28
|
+
# @param stop_loss_price [Float] Planned stop loss price
|
|
29
|
+
# @param lot_size [Integer] Lot size for the instrument (default: 1)
|
|
30
|
+
# @return [Integer] Number of shares/lots to trade
|
|
31
|
+
def self.calculate(account_balance:, risk_percent:, entry_price:, stop_loss_price:, lot_size: 1)
|
|
32
|
+
return 0 if account_balance <= 0 || entry_price <= 0 || stop_loss_price <= 0
|
|
33
|
+
return 0 if entry_price == stop_loss_price
|
|
34
|
+
|
|
35
|
+
risk_amount = account_balance * (risk_percent / 100.0)
|
|
36
|
+
risk_per_share = (entry_price - stop_loss_price).abs
|
|
37
|
+
|
|
38
|
+
return 0 if risk_per_share.zero?
|
|
39
|
+
|
|
40
|
+
raw_shares = (risk_amount / risk_per_share).floor
|
|
41
|
+
(raw_shares / lot_size).floor * lot_size
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Calculate position size using Kelly Criterion.
|
|
45
|
+
#
|
|
46
|
+
# @param win_rate [Float] Historical win rate (0.0 to 1.0)
|
|
47
|
+
# @param avg_win [Float] Average winning trade amount
|
|
48
|
+
# @param avg_loss [Float] Average losing trade amount (positive number)
|
|
49
|
+
# @param account_balance [Float] Total account balance
|
|
50
|
+
# @param entry_price [Float] Planned entry price
|
|
51
|
+
# @param fraction [Float] Kelly fraction to use (default: 0.5 for half-Kelly)
|
|
52
|
+
# @return [Integer] Number of shares to trade
|
|
53
|
+
def self.kelly(win_rate:, avg_win:, avg_loss:, account_balance:, entry_price:, fraction: 0.5)
|
|
54
|
+
return 0 if account_balance <= 0 || entry_price <= 0
|
|
55
|
+
return 0 if avg_loss.zero? || win_rate <= 0 || win_rate >= 1
|
|
56
|
+
|
|
57
|
+
# Kelly formula: f = (bp - q) / b
|
|
58
|
+
# where b = avg_win/avg_loss, p = win_rate, q = 1 - win_rate
|
|
59
|
+
b = avg_win / avg_loss
|
|
60
|
+
kelly_fraction = ((b * win_rate) - (1 - win_rate)) / b
|
|
61
|
+
|
|
62
|
+
return 0 if kelly_fraction <= 0
|
|
63
|
+
|
|
64
|
+
# Apply fractional Kelly
|
|
65
|
+
adjusted_fraction = kelly_fraction * fraction
|
|
66
|
+
risk_amount = account_balance * adjusted_fraction
|
|
67
|
+
shares = (risk_amount / entry_price).floor
|
|
68
|
+
|
|
69
|
+
[shares, 0].max
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Calculate position size based on volatility (ATR-based).
|
|
73
|
+
#
|
|
74
|
+
# @param account_balance [Float] Total account balance
|
|
75
|
+
# @param risk_percent [Float] Percentage of account to risk per trade
|
|
76
|
+
# @param entry_price [Float] Planned entry price
|
|
77
|
+
# @param atr [Float] Current ATR value
|
|
78
|
+
# @param atr_multiplier [Float] Multiplier for ATR-based stop (default: 2.0)
|
|
79
|
+
# @param lot_size [Integer] Lot size for the instrument (default: 1)
|
|
80
|
+
# @return [Integer] Number of shares/lots to trade
|
|
81
|
+
def self.volatility_based(account_balance:, risk_percent:, entry_price:, atr:, atr_multiplier: 2.0, lot_size: 1)
|
|
82
|
+
return 0 if account_balance <= 0 || entry_price <= 0 || atr <= 0
|
|
83
|
+
|
|
84
|
+
stop_distance = atr * atr_multiplier
|
|
85
|
+
stop_loss_price = entry_price - stop_distance
|
|
86
|
+
|
|
87
|
+
calculate(
|
|
88
|
+
account_balance: account_balance,
|
|
89
|
+
risk_percent: risk_percent,
|
|
90
|
+
entry_price: entry_price,
|
|
91
|
+
stop_loss_price: stop_loss_price,
|
|
92
|
+
lot_size: lot_size
|
|
93
|
+
)
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Calculate stop loss and take profit levels.
|
|
98
|
+
#
|
|
99
|
+
# @example Calculate stop loss
|
|
100
|
+
# levels = DhanHQ::Risk::SLCalculator.stop_loss(
|
|
101
|
+
# entry_price: 2500,
|
|
102
|
+
# risk_percent: 2.0,
|
|
103
|
+
# atr: 50
|
|
104
|
+
# )
|
|
105
|
+
# #=> { fixed: 2450, atr_based: 2400 }
|
|
106
|
+
#
|
|
107
|
+
class SLCalculator
|
|
108
|
+
# Calculate fixed percentage stop loss.
|
|
109
|
+
#
|
|
110
|
+
# @param entry_price [Float] Entry price
|
|
111
|
+
# @param risk_percent [Float] Risk percentage (e.g., 2.0 for 2%)
|
|
112
|
+
# @return [Float] Stop loss price
|
|
113
|
+
def self.percentage(entry_price:, risk_percent:)
|
|
114
|
+
return 0.0 if entry_price <= 0 || risk_percent <= 0
|
|
115
|
+
|
|
116
|
+
entry_price * (1 - (risk_percent / 100.0))
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Calculate ATR-based stop loss.
|
|
120
|
+
#
|
|
121
|
+
# @param entry_price [Float] Entry price
|
|
122
|
+
# @param atr [Float] Current ATR value
|
|
123
|
+
# @param multiplier [Float] ATR multiplier (default: 2.0)
|
|
124
|
+
# @return [Float] Stop loss price
|
|
125
|
+
def self.atr_based(entry_price:, atr:, multiplier: 2.0)
|
|
126
|
+
return 0.0 if entry_price <= 0 || atr <= 0
|
|
127
|
+
|
|
128
|
+
entry_price - (atr * multiplier)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Calculate support-based stop loss.
|
|
132
|
+
#
|
|
133
|
+
# @param entry_price [Float] Entry price
|
|
134
|
+
# @param support_levels [Array<Float>] Array of support levels
|
|
135
|
+
# @param buffer [Float] Buffer below support (default: 0.5%)
|
|
136
|
+
# @return [Float] Stop loss price (below nearest support)
|
|
137
|
+
def self.support_based(entry_price:, support_levels:, buffer: 0.005)
|
|
138
|
+
return 0.0 if entry_price <= 0 || support_levels.nil? || support_levels.empty?
|
|
139
|
+
|
|
140
|
+
# Find nearest support below entry price
|
|
141
|
+
valid_supports = support_levels.select { |s| s < entry_price }
|
|
142
|
+
return 0.0 if valid_supports.empty?
|
|
143
|
+
|
|
144
|
+
nearest_support = valid_supports.max
|
|
145
|
+
nearest_support * (1 - buffer)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Calculate take profit based on risk-reward ratio.
|
|
149
|
+
#
|
|
150
|
+
# @param entry_price [Float] Entry price
|
|
151
|
+
# @param stop_loss_price [Float] Stop loss price
|
|
152
|
+
# @param risk_reward_ratio [Float] Desired risk:reward ratio (e.g., 2.0 for 1:2)
|
|
153
|
+
# @return [Float] Take profit price
|
|
154
|
+
def self.take_profit(entry_price:, stop_loss_price:, risk_reward_ratio: 2.0)
|
|
155
|
+
return 0.0 if entry_price <= 0 || stop_loss_price <= 0
|
|
156
|
+
|
|
157
|
+
risk = (entry_price - stop_loss_price).abs
|
|
158
|
+
entry_price + (risk * risk_reward_ratio)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Calculate trailing stop level.
|
|
162
|
+
#
|
|
163
|
+
# @param highest_price [Float] Highest price since entry
|
|
164
|
+
# @param atr [Float] Current ATR value
|
|
165
|
+
# @param multiplier [Float] ATR multiplier (default: 2.0)
|
|
166
|
+
# @return [Float] Trailing stop price
|
|
167
|
+
def self.trailing_stop(highest_price:, atr:, multiplier: 2.0)
|
|
168
|
+
return 0.0 if highest_price <= 0 || atr <= 0
|
|
169
|
+
|
|
170
|
+
highest_price - (atr * multiplier)
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# Manage trailing stop updates.
|
|
175
|
+
#
|
|
176
|
+
# @example Create trail manager
|
|
177
|
+
# trail = DhanHQ::Risk::TrailManager.new(
|
|
178
|
+
# entry_price: 2500,
|
|
179
|
+
# initial_stop: 2450,
|
|
180
|
+
# atr: 50,
|
|
181
|
+
# trail_multiplier: 2.0
|
|
182
|
+
# )
|
|
183
|
+
# trail.update(2600) #=> { stop: 2500, triggered: false }
|
|
184
|
+
#
|
|
185
|
+
class TrailManager
|
|
186
|
+
attr_reader :entry_price, :initial_stop, :current_stop, :highest_price, :atr, :trail_multiplier
|
|
187
|
+
|
|
188
|
+
def initialize(entry_price:, initial_stop:, atr:, trail_multiplier: 2.0)
|
|
189
|
+
@entry_price = entry_price
|
|
190
|
+
@initial_stop = initial_stop
|
|
191
|
+
@current_stop = initial_stop
|
|
192
|
+
@highest_price = entry_price
|
|
193
|
+
@atr = atr
|
|
194
|
+
@trail_multiplier = trail_multiplier
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# Update trail with new price.
|
|
198
|
+
#
|
|
199
|
+
# @param current_price [Float] Current market price
|
|
200
|
+
# @return [Hash] Hash with :stop, :highest, :triggered
|
|
201
|
+
def update(current_price)
|
|
202
|
+
return { stop: @current_stop, highest: @highest_price, triggered: false } if current_price <= 0
|
|
203
|
+
|
|
204
|
+
# Update highest price
|
|
205
|
+
@highest_price = current_price if current_price > @highest_price
|
|
206
|
+
|
|
207
|
+
# Calculate new trailing stop
|
|
208
|
+
new_stop = @highest_price - (@atr * @trail_multiplier)
|
|
209
|
+
|
|
210
|
+
# Stop can only move up, never down
|
|
211
|
+
@current_stop = new_stop if new_stop > @current_stop
|
|
212
|
+
|
|
213
|
+
# Check if stop is triggered
|
|
214
|
+
triggered = current_price <= @current_stop
|
|
215
|
+
|
|
216
|
+
{
|
|
217
|
+
stop: @current_stop,
|
|
218
|
+
highest: @highest_price,
|
|
219
|
+
triggered: triggered
|
|
220
|
+
}
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# Check if stop is triggered at current price.
|
|
224
|
+
#
|
|
225
|
+
# @param current_price [Float] Current market price
|
|
226
|
+
# @return [Boolean]
|
|
227
|
+
def triggered?(current_price)
|
|
228
|
+
current_price <= @current_stop
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# Calculate profit from entry to current price.
|
|
232
|
+
#
|
|
233
|
+
# @param current_price [Float] Current market price
|
|
234
|
+
# @return [Float] Profit per share
|
|
235
|
+
def profit(current_price)
|
|
236
|
+
current_price - entry_price
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# Calculate profit percentage from entry to current price.
|
|
240
|
+
#
|
|
241
|
+
# @param current_price [Float] Current market price
|
|
242
|
+
# @return [Float] Profit percentage
|
|
243
|
+
def profit_percent(current_price)
|
|
244
|
+
return 0.0 if entry_price.zero?
|
|
245
|
+
|
|
246
|
+
(profit(current_price) / entry_price * 100).round(2)
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
end
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DhanHQ
|
|
4
|
+
module Skills
|
|
5
|
+
# Base class for all trading skills.
|
|
6
|
+
#
|
|
7
|
+
# Provides a DSL for defining parameters, steps, and execution logic.
|
|
8
|
+
# Skills are stateless — context is passed through and returned.
|
|
9
|
+
#
|
|
10
|
+
# @example
|
|
11
|
+
# class BuyAtmCall < DhanHQ::Skills::Base
|
|
12
|
+
# param :symbol, type: :string, required: true
|
|
13
|
+
# param :expiry, type: :string, required: true
|
|
14
|
+
# param :quantity, type: :integer, default: 50
|
|
15
|
+
#
|
|
16
|
+
# step :find_instrument
|
|
17
|
+
# step :get_spot_price
|
|
18
|
+
# step :prepare_intent
|
|
19
|
+
#
|
|
20
|
+
# def find_instrument(ctx)
|
|
21
|
+
# ctx[:instrument] = DhanHQ::Models::Instrument.find("IDX_I", ctx[:symbol])
|
|
22
|
+
# ctx
|
|
23
|
+
# end
|
|
24
|
+
#
|
|
25
|
+
# def get_spot_price(ctx)
|
|
26
|
+
# ctx[:spot_price] = ctx[:instrument].ltp
|
|
27
|
+
# ctx
|
|
28
|
+
# end
|
|
29
|
+
#
|
|
30
|
+
# def prepare_intent(ctx)
|
|
31
|
+
# ctx[:intent] = { symbol: ctx[:symbol], spot: ctx[:spot_price] }
|
|
32
|
+
# ctx
|
|
33
|
+
# end
|
|
34
|
+
# end
|
|
35
|
+
#
|
|
36
|
+
class Base
|
|
37
|
+
class << self
|
|
38
|
+
# Define a parameter for this skill.
|
|
39
|
+
#
|
|
40
|
+
# @param name [Symbol] parameter name
|
|
41
|
+
# @param type [Symbol] :string, :integer, :number, :boolean
|
|
42
|
+
# @param required [Boolean] whether the parameter is required
|
|
43
|
+
# @param default [Object] default value if not provided
|
|
44
|
+
def param(name, type: :string, required: false, default: nil, description: nil)
|
|
45
|
+
@params ||= {}
|
|
46
|
+
@params[name] = { type: type, required: required, default: default, description: description }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Define a step in the skill execution sequence.
|
|
50
|
+
#
|
|
51
|
+
# @param name [Symbol] method name to call
|
|
52
|
+
# @param priority [Integer] execution order (lower = earlier)
|
|
53
|
+
def step(name, priority: 10)
|
|
54
|
+
@steps ||= []
|
|
55
|
+
@steps << { name: name, priority: priority }
|
|
56
|
+
@steps.sort_by! { |s| s[:priority] }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# MCP risk level for this skill (defaults to the most conservative tier
|
|
60
|
+
# so a skill that forgets to declare one fails safe/write-gated).
|
|
61
|
+
#
|
|
62
|
+
# @param level [String, nil] one of read_only, trade_adjacent_read, live_write, destructive_write
|
|
63
|
+
def risk(level = nil)
|
|
64
|
+
level ? (@risk = level) : (@risk || "destructive_write")
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# MCP policy scope required to invoke this skill.
|
|
68
|
+
#
|
|
69
|
+
# @param value [String, nil] e.g. "orders:read", "orders:write"
|
|
70
|
+
def scope(value = nil)
|
|
71
|
+
value ? (@scope = value) : (@scope || "orders:write")
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Human-readable description shown to MCP/LLM clients in tools/list.
|
|
75
|
+
#
|
|
76
|
+
# @param text [String, nil] one-line summary of what the skill does
|
|
77
|
+
def description(text = nil)
|
|
78
|
+
text ? (@description = text) : @description
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Accessor for defined parameters.
|
|
82
|
+
def params
|
|
83
|
+
@params || {}
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Accessor for defined steps.
|
|
87
|
+
def steps
|
|
88
|
+
@steps || []
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Validate that all required parameters are present.
|
|
92
|
+
#
|
|
93
|
+
# @param args [Hash] provided parameters
|
|
94
|
+
# @raise [ArgumentError] if required parameters are missing
|
|
95
|
+
def validate_params!(args)
|
|
96
|
+
params.each do |name, config|
|
|
97
|
+
next unless config[:required]
|
|
98
|
+
|
|
99
|
+
value = args[name] || args[name.to_s]
|
|
100
|
+
next unless value.nil?
|
|
101
|
+
|
|
102
|
+
raise ArgumentError, "Missing required parameter: #{name}"
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Execute the skill with the given arguments.
|
|
108
|
+
#
|
|
109
|
+
# @param args [Hash] skill parameters (symbol or string keys)
|
|
110
|
+
# @return [Hash] context with all accumulated state
|
|
111
|
+
# @raise [ArgumentError] if required parameters are missing
|
|
112
|
+
def call(args = {})
|
|
113
|
+
ctx = build_context(args)
|
|
114
|
+
self.class.validate_params!(ctx)
|
|
115
|
+
|
|
116
|
+
self.class.steps.each do |step|
|
|
117
|
+
result = send(step[:name], ctx)
|
|
118
|
+
ctx = result if result.is_a?(Hash)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
ctx
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Skill name (defaults to class name).
|
|
125
|
+
def name
|
|
126
|
+
self.class.name || self.class.to_s
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Skill description (declare via the class-level `description` macro; falls back to class name).
|
|
130
|
+
def description
|
|
131
|
+
self.class.description || self.class.to_s
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# List of parameter definitions for this skill.
|
|
135
|
+
def param_definitions
|
|
136
|
+
self.class.params
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
private
|
|
140
|
+
|
|
141
|
+
# Real DhanHQ::Models::OptionChain#fetch shape: { last_price:, strikes: [{ strike:, call: {...}, put: {...} }] }.
|
|
142
|
+
# Nearest strike to a target price — always returns an entry (never nil) unless the chain is empty.
|
|
143
|
+
def nearest_strike(chain, target_price)
|
|
144
|
+
strikes = chain[:strikes]
|
|
145
|
+
return nil if strikes.nil? || strikes.empty?
|
|
146
|
+
|
|
147
|
+
strikes.min_by { |s| (s[:strike].to_f - target_price.to_f).abs }
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Exact strike match within tolerance — nil if no strike sits on that price.
|
|
151
|
+
def find_strike(chain, target_price, tolerance: 0.001)
|
|
152
|
+
strikes = chain[:strikes]
|
|
153
|
+
return nil if strikes.nil? || strikes.empty?
|
|
154
|
+
|
|
155
|
+
strikes.find { |s| (s[:strike].to_f - target_price.to_f).abs < tolerance }
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def leg_side(strike_entry, option_type)
|
|
159
|
+
option_type == "CE" ? strike_entry[:call] : strike_entry[:put]
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def leg_security_id(strike_entry, option_type)
|
|
163
|
+
leg_side(strike_entry, option_type)[:security_id]
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def leg_premium(strike_entry, option_type)
|
|
167
|
+
leg_side(strike_entry, option_type)[:last_price]
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def build_context(args)
|
|
171
|
+
ctx = {}
|
|
172
|
+
|
|
173
|
+
self.class.params.each do |name, config|
|
|
174
|
+
value = args[name] || args[name.to_s]
|
|
175
|
+
value = config[:default] if value.nil? && config[:default]
|
|
176
|
+
ctx[name] = value
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
ctx
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|